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'] },