diff --git a/packages/core/src/components/gesture/gesture.tsx b/packages/core/src/components/gesture/gesture.tsx index bc0bb258de..8d98ce94ec 100644 --- a/packages/core/src/components/gesture/gesture.tsx +++ b/packages/core/src/components/gesture/gesture.tsx @@ -38,7 +38,7 @@ export class Gesture { @Prop() gestureName: string = ''; @Prop() gesturePriority: number = 0; @Prop() maxAngle: number = 40; - @Prop() threshold: number = 20; + @Prop() threshold: number = 10; @Prop() type: string = 'pan'; @Prop() canStart: GestureCallback; @@ -209,7 +209,7 @@ export class Gesture { const detail = this.detail; this.calcGestureData(ev); if (this.pan.detect(detail.currentX, detail.currentY)) { - if (this.pan.isGesture() !== 0) { + if (this.pan.isGesture()) { if (!this.tryToCapturePan()) { this.abortGesture(); } @@ -463,6 +463,7 @@ export interface GestureDetail { deltaX?: number; deltaY?: number; timeStamp?: number; + data?: any; } diff --git a/packages/core/src/components/gesture/recognizers.ts b/packages/core/src/components/gesture/recognizers.ts index 230b65e289..baf7537296 100644 --- a/packages/core/src/components/gesture/recognizers.ts +++ b/packages/core/src/components/gesture/recognizers.ts @@ -7,13 +7,14 @@ export class PanRecognizer { private dirty: boolean = false; private threshold: number; private maxCosine: number; + private isDirX: boolean; private angle = 0; private isPan = 0; - - constructor(private direction: string, threshold: number, maxAngle: number) { + constructor(direction: string, threshold: number, maxAngle: number) { const radians = maxAngle * (Math.PI / 180); + this.isDirX = direction === 'x'; this.maxCosine = Math.cos(radians); this.threshold = threshold * threshold; } @@ -35,32 +36,31 @@ export class PanRecognizer { const deltaY = (y - this.startY); const distance = deltaX * deltaX + deltaY * deltaY; - if (distance >= this.threshold) { - var angle = Math.atan2(deltaY, deltaX); - var cosine = (this.direction === 'y') - ? Math.sin(angle) - : Math.cos(angle); + if (distance < this.threshold) { + return false; + } + const hypotenuse = Math.sqrt(distance); + const cosine = ((this.isDirX) ? deltaX : deltaY) / hypotenuse; - this.angle = angle; + if (cosine > this.maxCosine) { + this.isPan = 1; - if (cosine > this.maxCosine) { - this.isPan = 1; + } else if (cosine < -this.maxCosine) { + this.isPan = -1; - } else if (cosine < -this.maxCosine) { - this.isPan = -1; - - } else { - this.isPan = 0; - } - - this.dirty = false; - return true; + } else { + this.isPan = 0; } - return false; + this.dirty = false; + return true; } - isGesture(): number { + isGesture(): boolean { + return this.isPan !== 0; + } + + getDirection(): number { return this.isPan; } } diff --git a/packages/core/src/components/item/item.scss b/packages/core/src/components/item/item.scss index 702ee852cb..cc7cdd18fb 100644 --- a/packages/core/src/components/item/item.scss +++ b/packages/core/src/components/item/item.scss @@ -97,3 +97,10 @@ ion-input.item { background: transparent; cursor: pointer; } + +[reorderAnchor] { + display: none; + + pointer-events: all !important; + touch-action: manipulation; +} diff --git a/packages/core/src/components/item/item.tsx b/packages/core/src/components/item/item.tsx index dac4161477..6f1cb9d4fb 100644 --- a/packages/core/src/components/item/item.tsx +++ b/packages/core/src/components/item/item.tsx @@ -20,9 +20,6 @@ export class Item { private itemStyles: { [key: string]: CssClassMap } = Object.create(null); private label: any; - // TODO get reorder from a parent list/group - @State() reorder: boolean = false; - @Element() private el: HTMLElement; @Prop() mode: string; @@ -131,10 +128,6 @@ export class Item { - { this.reorder - ? - : null - }
diff --git a/packages/core/src/components/list/list.tsx b/packages/core/src/components/list/list.tsx index fb2d3e8a2c..1de4a40c9e 100644 --- a/packages/core/src/components/list/list.tsx +++ b/packages/core/src/components/list/list.tsx @@ -1,4 +1,4 @@ -import { Component, Method, State } from '@stencil/core'; +import { Component, Method, Prop, State } from '@stencil/core'; import { ItemSliding } from '../item-sliding/item-sliding'; @@ -15,9 +15,18 @@ import { ItemSliding } from '../item-sliding/item-sliding'; } }) export class List { + @State() openContainer: ItemSliding; + @Prop() radioGroup: boolean; render() { + if (this.radioGroup) { + return ( + + + + ); + } return ; } diff --git a/packages/core/src/components/reorder/reorder-group.tsx b/packages/core/src/components/reorder/reorder-group.tsx new file mode 100644 index 0000000000..3173892942 --- /dev/null +++ b/packages/core/src/components/reorder/reorder-group.tsx @@ -0,0 +1,383 @@ +import { Component, Element, Prop, PropDidChange, State } from '@stencil/core'; +import { GestureDetail } from '../../index'; +import { reorderArray } from '../../utils/helpers'; +import { CSS_PROP } from '../animation-controller/constants'; + +// const AUTO_SCROLL_MARGIN = 60; +// const SCROLL_JUMP = 10; +const ITEM_REORDER_ACTIVE = 'reorder-active'; + + +export class ReorderIndexes { + constructor(public from: number, public to: number) {} + + applyTo(array: any) { + reorderArray(array, this); + } +} + +/** + * @name ReorderGroup + * @description + * Item reorder adds the ability to change an item's order in a group. + * It can be used within an `ion-list` or `ion-item-group` to provide a + * visual drag and drop interface. + * + * ## Grouping Items + * + * All reorderable items must be grouped in the same element. If an item + * should not be reordered, it shouldn't be included in this group. For + * example, the following code works because the items are grouped in the + * ``: + * + * ```html + * + * {% raw %}{{ item }}{% endraw %} + * + * ``` + * + * However, the below list includes a header that shouldn't be reordered: + * + * ```html + * + * Header + * {% raw %}{{ item }}{% endraw %} + * + * ``` + * + * In order to mix different sets of items, `ion-item-group` should be used to + * group the reorderable items: + * + * ```html + * + * Header + * + * {% raw %}{{ item }}{% endraw %} + * + * + * ``` + * + * It's important to note that in this example, the `[reorder]` directive is applied to + * the `` instead of the ``. This way makes it possible to + * mix items that should and shouldn't be reordered. + * + * + * ## Implementing the Reorder Function + * + * When the item is dragged and dropped into the new position, the `(ionItemReorder)` event is + * emitted. This event provides the initial index (from) and the new index (to) of the reordered + * item. For example, if the first item is dragged to the fifth position, the event will emit + * `{from: 0, to: 4}`. Note that the index starts at zero. + * + * A function should be called when the event is emitted that handles the reordering of the items. + * See [usage](#usage) below for some examples. + * + * + * @usage + * + * ```html + * + * Header + * + * {% raw %}{{ item }}{% endraw %} + * + * + * ``` + * + * ```ts + * class MyComponent { + * items = []; + * + * constructor() { + * for (let x = 0; x < 5; x++) { + * this.items.push(x); + * } + * } + * + * reorderItems(indexes) { + * let element = this.items[indexes.from]; + * this.items.splice(indexes.from, 1); + * this.items.splice(indexes.to, 0, element); + * } + * } + * ``` + * + * Ionic also provides a helper function called `reorderArray` to + * reorder the array of items. This can be used instead: + * + * ```ts + * import { reorderArray } from 'ionic-angular'; + * + * class MyComponent { + * items = []; + * + * constructor() { + * for (let x = 0; x < 5; x++) { + * this.items.push(x); + * } + * } + * + * reorderItems(indexes) { + * this.items = reorderArray(this.items, indexes); + * } + * } + * ``` + * Alternatevely you can execute helper function inside template: + * + * ```html + * + * Header + * + * {% raw %}{{ item }}{% endraw %} + * + * + * ``` + * + * @demo /docs/demos/src/item-reorder/ + * @see {@link /docs/components#lists List Component Docs} + * @see {@link ../../list/List List API Docs} + * @see {@link ../Item Item API Docs} + */ +@Component({ + tag: 'ion-reorder-group', + styleUrl: 'reorder.scss' +}) +export class ReorderGroup { + + private selectedItemEle: HTMLElement = null; + private selectedItemHeight: number; + private lastToIndex: number; + private lastYcoord: number; + private topOfList: number; + private cachedHeights: number[] = []; + private containerEle: HTMLElement; + + @State() _enabled: boolean = false; + @State() _iconVisible: boolean = false; + @State() _actived: boolean = false; + + @Element() ele: HTMLElement; + + @Prop() enabled: boolean = false; + + /** + * @input {string} Which side of the view the ion-reorder should be placed. Default `"end"`. + */ + @Prop() side: string; + + @PropDidChange('enabled') + enabledChanged(enabled: boolean) { + if (enabled) { + this._enabled = true; + Context.dom.raf(() => { + this._iconVisible = true; + }); + } else { + this._iconVisible = false; + setTimeout(() => this._enabled = false, 400); + } + } + + ionViewDidLoad() { + this.containerEle = this.ele.querySelector('ion-gesture') as HTMLElement; + } + + ionViewDidUnload() { + this.onDragEnd(); + } + + private canStart(ev: GestureDetail): boolean { + if (this.selectedItemEle) { + return false; + } + const target = ev.event.target as HTMLElement; + const reorderEle = target.closest('[reorderAnchor]') as HTMLElement; + if (!reorderEle) { + return false; + } + const item = findReorderItem(reorderEle, this.containerEle); + if (!item) { + console.error('reorder node not found'); + return false; + } + ev.data = item; + return true; + } + + private onDragStart(ev: GestureDetail) { + const item = this.selectedItemEle = ev.data; + const heights = this.cachedHeights; + heights.length = 0; + const ele = this.containerEle; + const children: any = ele.children; + if (!children || children.length === 0) { + return; + } + + let sum = 0; + for (let i = 0, ilen = children.length; i < ilen; i++) { + var child = children[i]; + sum += child.offsetHeight; + heights.push(sum); + child.$ionIndex = i; + } + + this.topOfList = item.getBoundingClientRect().top; + this._actived = true; + this.lastYcoord = -100; + this.lastToIndex = indexForItem(item); + this.selectedItemHeight = item.offsetHeight; + + item.classList.add(ITEM_REORDER_ACTIVE); + } + + private onDragMove(ev: GestureDetail) { + const selectedItem = this.selectedItemEle; + if (!selectedItem) { + return; + } + // ev.event.preventDefault(); + + // // Get coordinate + const posY = ev.deltaY; + + // Scroll if we reach the scroll margins + // const scrollPosition = this.scroll(posY); + // Only perform hit test if we moved at least 30px from previous position + if (Math.abs(posY - this.lastYcoord) > 30) { + let toIndex = this.itemIndexForDelta(posY); + if (toIndex !== undefined && (toIndex !== this.lastToIndex)) { + let fromIndex = indexForItem(selectedItem); + this.lastToIndex = toIndex; + this.lastYcoord = posY; + this._reorderMove(fromIndex, toIndex, this.selectedItemHeight); + } + } + + // Update selected item position + (selectedItem.style as any)[CSS_PROP.transformProp] = `translateY(${posY}px)`; + } + + private onDragEnd() { + this._actived = false; + const selectedItem = this.selectedItemEle; + if (!selectedItem) { + return; + } + // if (ev.event) { + // ev.event.preventDefault(); + // ev.event.stopPropagation(); + // } + + const toIndex = this.lastToIndex; + const fromIndex = indexForItem(selectedItem); + + const ref = (fromIndex < toIndex) + ? this.containerEle.children[toIndex + 1] + : this.containerEle.children[toIndex]; + + this.containerEle.insertBefore(this.selectedItemEle, ref); + + const children = this.containerEle.children; + const len = children.length; + const transform = CSS_PROP.transformProp; + for (let i = 0; i < len; i++) { + (children[i] as any).style[transform] = ''; + } + + const reorderInactive = () => { + this.selectedItemEle.style.transition = ''; + this.selectedItemEle.classList.remove(ITEM_REORDER_ACTIVE); + this.selectedItemEle = null; + }; + if (toIndex === fromIndex) { + selectedItem.style.transition = 'transform 200ms ease-in-out'; + setTimeout(reorderInactive, 200); + } else { + reorderInactive(); + } + } + + private itemIndexForDelta(deltaY: number): number { + const heights = this.cachedHeights; + let sum = deltaY + this.topOfList - (this.selectedItemHeight / 2); + for (var i = 0; i < heights.length; i++) { + if (heights[i] > sum) { + return i; + } + } + return null; + } + + private _reorderMove(fromIndex: number, toIndex: number, itemHeight: number) { + /********* DOM WRITE ********* */ + const children = this.containerEle.children; + const transform = CSS_PROP.transformProp; + for (var i = 0; i < children.length; i++) { + const style = (children[i] as any).style; + let value = ''; + if (i > fromIndex && i <= toIndex) { + value = `translateY(${-itemHeight}px)`; + } else if (i < fromIndex && i >= toIndex) { + value = `translateY(${itemHeight}px)`; + } + style[transform] = value; + } + } + + hostData() { + return { + class: { + 'reorder-enabled': this._enabled, + 'reorder-list-active': this._actived, + 'reorder-visible': this._iconVisible, + 'reorder-side-start': this.side === 'start' + } + }; + } + + render() { + return ( + + + + ); + } +} + +/** + * @hidden + */ +function indexForItem(element: any): number { + return element['$ionIndex']; +} + +/** + * @hidden + */ +function findReorderItem(node: HTMLElement, container: HTMLElement): HTMLElement { + let nested = 0; + let parent; + while (node && nested < 6) { + parent = node.parentNode as HTMLElement; + if (parent === container) { + return node; + } + node = parent; + nested++; + } + return null; +} diff --git a/packages/core/src/components/reorder/reorder.scss b/packages/core/src/components/reorder/reorder.scss new file mode 100644 index 0000000000..d4144d3149 --- /dev/null +++ b/packages/core/src/components/reorder/reorder.scss @@ -0,0 +1,64 @@ +@import "../../themes/ionic.globals"; + +$reorder-initial-transform: 160% !default; + +// Reorder group general +// -------------------------------------------------- + +.reorder-enabled [reorderAnchor] { + display: block; +} + +.reorder-list-active ion-gesture > * { + transition: transform 300ms; + + will-change: transform; +} + +.reorder-list-active ion-gesture *:not([reorderAnchor]) { + pointer-events: none; +} + +.reorder-active { + position: relative; + z-index: 100; + + box-shadow: 0 0 10px rgba(0, 0, 0, .4); + opacity: .8; + transition: none !important; + + pointer-events: none; +} + + +// Reorder icon +// -------------------------------------------------- + +ion-reorder { + @include transform(translate3d($reorder-initial-transform, 0, 0)); + + margin-top: auto !important; + margin-bottom: auto !important; + + font-size: 1.7em; + opacity: .25; + + line-height: 0; + + transition: transform 140ms ease-in; +} + +ion-reorder ion-icon { + pointer-events: none; +} + +.reorder-side-start ion-reorder { + @include transform(translate3d(-$reorder-initial-transform, 0, 0)); + + order: -1; +} + +.reorder-visible ion-reorder { + @include transform(translate3d(0, 0, 0)); +} + diff --git a/packages/core/src/components/reorder/reorder.tsx b/packages/core/src/components/reorder/reorder.tsx new file mode 100644 index 0000000000..8100af5c98 --- /dev/null +++ b/packages/core/src/components/reorder/reorder.tsx @@ -0,0 +1,20 @@ +import { Component } from '@stencil/core'; + +@Component({ + tag: 'ion-reorder', +}) +export class ItemReorder { + + hostData()  { + return { + attrs: { + 'reorderAnchor': '', + } + }; + } + + render() { + return ; + } +} + diff --git a/packages/core/src/components/reorder/test/basic.html b/packages/core/src/components/reorder/test/basic.html new file mode 100644 index 0000000000..527a7920a3 --- /dev/null +++ b/packages/core/src/components/reorder/test/basic.html @@ -0,0 +1,87 @@ + + + + + + Ionic Reorder + + + + + + + + + Item Reorder + + Toggle + + + + + + + + + Item 1 + + + Item 2 + + + Item 3 + + + + Item 4 + + + + Item 5 + + + + Item 6 + + + + Item 7 + + + + Item 8 + + + + Item 9 + + + + Item 10 + + + + Item 11 + + + + Item 12 + + + Item 13 + + + + + + + + + + + diff --git a/packages/core/src/utils/helpers.ts b/packages/core/src/utils/helpers.ts index b205e1ba77..1f0f625c03 100644 --- a/packages/core/src/utils/helpers.ts +++ b/packages/core/src/utils/helpers.ts @@ -268,3 +268,13 @@ export function hasFocusedTextInput() { } return false; } + +/** + * @private + */ +export function reorderArray(array: any[], indexes: {from: number, to: number}): any[] { + const element = array[indexes.from]; + array.splice(indexes.from, 1); + array.splice(indexes.to, 0, element); + return array; +} diff --git a/packages/core/stencil.config.js b/packages/core/stencil.config.js index 22e7c9bb5a..fadf716fe4 100644 --- a/packages/core/stencil.config.js +++ b/packages/core/stencil.config.js @@ -23,6 +23,7 @@ exports.config = { { components: ['ion-modal', 'ion-modal-controller'] }, { components: ['ion-popover', 'ion-popover-controller'] }, { components: ['ion-radio', 'ion-radio-group'] }, + { components: ['ion-reorder', 'ion-reorder-group'] }, { components: ['ion-searchbar'] }, { components: ['ion-segment', 'ion-segment-button'] }, { components: ['ion-select', 'ion-select-option', 'ion-select-popover'] },