feat(reorder): adds ion-reorder-group to core

This commit is contained in:
Manuel Mtz-Almeida
2017-09-20 11:44:27 +02:00
parent 52b3a4ce96
commit bdf4f60e9e
11 changed files with 606 additions and 31 deletions

View File

@ -38,7 +38,7 @@ export class Gesture {
@Prop() gestureName: string = ''; @Prop() gestureName: string = '';
@Prop() gesturePriority: number = 0; @Prop() gesturePriority: number = 0;
@Prop() maxAngle: number = 40; @Prop() maxAngle: number = 40;
@Prop() threshold: number = 20; @Prop() threshold: number = 10;
@Prop() type: string = 'pan'; @Prop() type: string = 'pan';
@Prop() canStart: GestureCallback; @Prop() canStart: GestureCallback;
@ -209,7 +209,7 @@ export class Gesture {
const detail = this.detail; const detail = this.detail;
this.calcGestureData(ev); this.calcGestureData(ev);
if (this.pan.detect(detail.currentX, detail.currentY)) { if (this.pan.detect(detail.currentX, detail.currentY)) {
if (this.pan.isGesture() !== 0) { if (this.pan.isGesture()) {
if (!this.tryToCapturePan()) { if (!this.tryToCapturePan()) {
this.abortGesture(); this.abortGesture();
} }
@ -463,6 +463,7 @@ export interface GestureDetail {
deltaX?: number; deltaX?: number;
deltaY?: number; deltaY?: number;
timeStamp?: number; timeStamp?: number;
data?: any;
} }

View File

@ -7,13 +7,14 @@ export class PanRecognizer {
private dirty: boolean = false; private dirty: boolean = false;
private threshold: number; private threshold: number;
private maxCosine: number; private maxCosine: number;
private isDirX: boolean;
private angle = 0; private angle = 0;
private isPan = 0; private isPan = 0;
constructor(direction: string, threshold: number, maxAngle: number) {
constructor(private direction: string, threshold: number, maxAngle: number) {
const radians = maxAngle * (Math.PI / 180); const radians = maxAngle * (Math.PI / 180);
this.isDirX = direction === 'x';
this.maxCosine = Math.cos(radians); this.maxCosine = Math.cos(radians);
this.threshold = threshold * threshold; this.threshold = threshold * threshold;
} }
@ -35,32 +36,31 @@ export class PanRecognizer {
const deltaY = (y - this.startY); const deltaY = (y - this.startY);
const distance = deltaX * deltaX + deltaY * deltaY; const distance = deltaX * deltaX + deltaY * deltaY;
if (distance >= this.threshold) { if (distance < this.threshold) {
var angle = Math.atan2(deltaY, deltaX); return false;
var cosine = (this.direction === 'y') }
? Math.sin(angle) const hypotenuse = Math.sqrt(distance);
: Math.cos(angle); const cosine = ((this.isDirX) ? deltaX : deltaY) / hypotenuse;
this.angle = angle; if (cosine > this.maxCosine) {
this.isPan = 1;
if (cosine > this.maxCosine) { } else if (cosine < -this.maxCosine) {
this.isPan = 1; this.isPan = -1;
} else if (cosine < -this.maxCosine) { } else {
this.isPan = -1; this.isPan = 0;
} else {
this.isPan = 0;
}
this.dirty = false;
return true;
} }
return false; this.dirty = false;
return true;
} }
isGesture(): number { isGesture(): boolean {
return this.isPan !== 0;
}
getDirection(): number {
return this.isPan; return this.isPan;
} }
} }

View File

@ -97,3 +97,10 @@ ion-input.item {
background: transparent; background: transparent;
cursor: pointer; cursor: pointer;
} }
[reorderAnchor] {
display: none;
pointer-events: all !important;
touch-action: manipulation;
}

View File

@ -20,9 +20,6 @@ export class Item {
private itemStyles: { [key: string]: CssClassMap } = Object.create(null); private itemStyles: { [key: string]: CssClassMap } = Object.create(null);
private label: any; private label: any;
// TODO get reorder from a parent list/group
@State() reorder: boolean = false;
@Element() private el: HTMLElement; @Element() private el: HTMLElement;
@Prop() mode: string; @Prop() mode: string;
@ -131,10 +128,6 @@ export class Item {
<slot></slot> <slot></slot>
</div> </div>
<slot name='end'></slot> <slot name='end'></slot>
{ this.reorder
? <ion-reorder></ion-reorder>
: null
}
</div> </div>
<div class='button-effect'></div> <div class='button-effect'></div>
</TagType> </TagType>

View File

@ -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'; import { ItemSliding } from '../item-sliding/item-sliding';
@ -15,9 +15,18 @@ import { ItemSliding } from '../item-sliding/item-sliding';
} }
}) })
export class List { export class List {
@State() openContainer: ItemSliding; @State() openContainer: ItemSliding;
@Prop() radioGroup: boolean;
render() { render() {
if (this.radioGroup) {
return (
<ion-radio-group>
<slot></slot>
</ion-radio-group>
);
}
return <slot></slot>; return <slot></slot>;
} }

View File

@ -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
* `<ion-list>`:
*
* ```html
* <ion-list reorder="true">
* <ion-item *ngFor="let item of items">{% raw %}{{ item }}{% endraw %}</ion-item>
* </ion-list>
* ```
*
* However, the below list includes a header that shouldn't be reordered:
*
* ```html
* <ion-list reorder="true">
* <ion-list-header>Header</ion-list-header>
* <ion-item *ngFor="let item of items">{% raw %}{{ item }}{% endraw %}</ion-item>
* </ion-list>
* ```
*
* In order to mix different sets of items, `ion-item-group` should be used to
* group the reorderable items:
*
* ```html
* <ion-list>
* <ion-list-header>Header</ion-list-header>
* <ion-item-group reorder="true">
* <ion-item *ngFor="let item of items">{% raw %}{{ item }}{% endraw %}</ion-item>
* </ion-item-group>
* </ion-list>
* ```
*
* It's important to note that in this example, the `[reorder]` directive is applied to
* the `<ion-item-group>` instead of the `<ion-list>`. 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
* <ion-list>
* <ion-list-header>Header</ion-list-header>
* <ion-item-group reorder="true" (ionItemReorder)="reorderItems($event)">
* <ion-item *ngFor="let item of items">{% raw %}{{ item }}{% endraw %}</ion-item>
* </ion-item-group>
* </ion-list>
* ```
*
* ```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
* <ion-list>
* <ion-list-header>Header</ion-list-header>
* <ion-item-group reorder="true" (ionItemReorder)="$event.applyTo(items)">
* <ion-item *ngFor="let item of items">{% raw %}{{ item }}{% endraw %}</ion-item>
* </ion-item-group>
* </ion-list>
* ```
*
* @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 (
<ion-gesture props={{
disableScroll: true,
canStart: this.canStart.bind(this),
onStart: this.onDragStart.bind(this),
onMove: this.onDragMove.bind(this),
onEnd: this.onDragEnd.bind(this),
enabled: this.enabled,
gestureName: 'reorder',
gesturePriority: 30,
type: 'pan',
direction: 'y',
threshold: 0,
attachTo: 'parent'
}}>
<slot></slot>
</ion-gesture>
);
}
}
/**
* @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;
}

View File

@ -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));
}

View File

@ -0,0 +1,20 @@
import { Component } from '@stencil/core';
@Component({
tag: 'ion-reorder',
})
export class ItemReorder {
hostData()  {
return {
attrs: {
'reorderAnchor': '',
}
};
}
render() {
return <ion-icon name='reorder'></ion-icon>;
}
}

View File

@ -0,0 +1,87 @@
<!DOCTYPE html>
<html dir="ltr">
<head>
<meta charset="UTF-8">
<title>Ionic Reorder</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
<script src="/dist/ionic.js"></script>
</head>
<body>
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>Item Reorder</ion-title>
<ion-buttons slot="end">
<ion-button onclick="toggleEdit()">Toggle</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-list>
<ion-reorder-group id="reorder">
<ion-item>
Item 1
<ion-reorder slot="end"></ion-reorder>
</ion-item>
<ion-item>Item 2
<ion-reorder slot="end"></ion-reorder>
</ion-item>
<ion-item>Item 3
<ion-icon reorderAnchor name="pizza" slot="end"></ion-icon>
</ion-item>
<ion-item>Item 4
<ion-icon reorderAnchor name="pizza" slot="end"></ion-icon>
</ion-item>
<ion-item>Item 5
<ion-icon reorderAnchor name="pizza" slot="end"></ion-icon>
</ion-item>
<ion-item>Item 6
<ion-icon reorderAnchor name="pizza" slot="end"></ion-icon>
</ion-item>
<ion-item>Item 7
<ion-icon reorderAnchor name="pizza" slot="end"></ion-icon>
</ion-item>
<ion-item>Item 8
<ion-icon reorderAnchor name="pizza" slot="end"></ion-icon>
</ion-item>
<ion-item>Item 9
<ion-icon reorderAnchor name="pizza" slot="end"></ion-icon>
</ion-item>
<ion-item>Item 10
<ion-icon reorderAnchor name="pizza" slot="end"></ion-icon>
</ion-item>
<ion-item>Item 11
<ion-icon reorderAnchor name="pizza" slot="end"></ion-icon>
</ion-item>
<ion-item>Item 12
<ion-icon reorderAnchor name="pizza" slot="end"></ion-icon>
</ion-item>
<ion-item>Item 13
<ion-icon reorderAnchor name="pizza" slot="end"></ion-icon>
</ion-item>
</ion-reorder-group>
</ion-list>
</ion-content>
</ion-app>
<script>
function toggleEdit() {
const list = document.getElementById('reorder');
list.enabled = !list.enabled;
}
</script>
</body>
</html>

View File

@ -268,3 +268,13 @@ export function hasFocusedTextInput() {
} }
return false; 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;
}

View File

@ -23,6 +23,7 @@ exports.config = {
{ components: ['ion-modal', 'ion-modal-controller'] }, { components: ['ion-modal', 'ion-modal-controller'] },
{ components: ['ion-popover', 'ion-popover-controller'] }, { components: ['ion-popover', 'ion-popover-controller'] },
{ components: ['ion-radio', 'ion-radio-group'] }, { components: ['ion-radio', 'ion-radio-group'] },
{ components: ['ion-reorder', 'ion-reorder-group'] },
{ components: ['ion-searchbar'] }, { components: ['ion-searchbar'] },
{ components: ['ion-segment', 'ion-segment-button'] }, { components: ['ion-segment', 'ion-segment-button'] },
{ components: ['ion-select', 'ion-select-option', 'ion-select-popover'] }, { components: ['ion-select', 'ion-select-option', 'ion-select-popover'] },