<template>
<div class="ion-item-sliding item-wrapper"
@touchstart="onDragStart"
@touchend="onDragEnd"
@touchmove="onDragMove"
:class="activeClass">
<slot></slot>
</div>
</template>
<style lang="scss" src="./style.scss"></style>
<script type="text/javascript">
/**
* @component Item/ItemSliding
* @description
*
* ## 列表组件 / ItemSliding滑动选择组件
*
* 这个组件是对Item组件的拓展, 当左右滑动时出现可选择的按钮, 这个组件在部分安卓机上卡顿明显, 使用起来效果不太好, 但是在IOS上很流畅.
*
*
* ### 子组件ItemSlidingOptions
*
* ItemSlidingOptions只能在ItemSliding组件中使用
*
* ### 如何使用
*
* ```
* // 引入
* import { ItemSlidingOptions, ItemSliding } from 'vimo'
* // 安装
* export default{
* components: { ItemSlidingOptions, ItemSliding }
* }
* ```
*
*
* @props {Boolean} disabled - 是否禁用
*
* @fires component:ItemSliding#onDrag
* @fires component:ItemSliding#onSwipe
* @fires component:ItemSliding#onSwipeRight
* @fires component:ItemSliding#onSwipeLeft
*
* @demo #/slidingList
* @see component:ItemOptions
*
* @usage
*
* <ItemSliding>
* <Item>
* <Avatar slot="item-left">
* <img src="./img/avatar-ts-woody.png">
* </Avatar>
* <Label>
* <h2>两边都有按钮</h2>
* <p>试试 ↔️️ 都滑动</p>
* </Label>
* </Item>
* <ItemSlidingOptions side="left">
* <Button color="primary" @click="clickText">
* <Icon name="text"></Icon>
* <span>Text</span>
* </Button>
* <Button color="secondary" @click="clickCall">
* <Icon name="call"></Icon>
* <span>Call</span>
* </Button>
* </ItemSlidingOptions>
* <ItemSlidingOptions side="right">
* <Button color="primary" @click="clickEmail">
* <Icon name="mail"></Icon>
* <span>Email</span>
* </Button>
* </ItemSlidingOptions>
* </ItemSliding>
* */
import { pointerCoord, transitionEnd } from '../../util/util.js'
const SWIPE_MARGIN = 30 * 2 // 触发swipe的值
const ELASTIC_FACTOR = 0.55 // 0.55
const ITEM_SIDE_FLAGS = {
None: 0,
Left: 1,
Right: 2,
Both: 3
}
const SLIDING_STATE = {
Disabled: 2, // 关闭状态
Enabled: 4, // 滑动状态, 不清楚方向和程度
Right: 8, // 向右滑动
Left: 16, // 向左滑动
SwipeRight: 32, // 向右滑动且滑动距离大
SwipeLeft: 64 // 向左滑动且滑动距离大
}
const DIRECTION_NONE = 1
const DIRECTION_LEFT = 2
const DIRECTION_RIGHT = 4
const DIRECTION_UP = 8
const DIRECTION_DOWN = 16
const MAX_DELTAX = 20
export default {
name: 'ItemSliding',
data () {
return {
timer: null,
isDragging: false, // 是否正在左右滑动
isDraggingConfirm: false, // 当次滚动方向确认与否
isDraggingFromStart: false, // 滚动是否从start开始
itemComponent: null, // 父组件Item的实例
ListComponent: null, // 父组件List的实例
leftOptions: null, // 左边的选项 实例
rightOptions: null, // 右边的选项 实例
optsWidthRightSide: 0, // 右边选项的宽度 number
optsWidthLeftSide: 0, // 左边选项的宽度 number
openAmount: 0, // 开启值 number
startX: 0, // 初始的位置 number
sides: 0, // 当前滑动的方向 ItemSideFlags的枚举值 标识options的left/right/both的状态
unregister: null, // itemComponent组件关闭动画的解绑函数
optsDirty: true, // boolean true:还未计算options的宽度,false:已计算
state: SLIDING_STATE.Disabled, // 滑动程度及状态
firstCoord: 0, // {x,y} 记录点击开始的位置,用于计算速度
firstTimestamp: new Date().getTime(), // number 记录点击开始的时间,用于计算速度
activeClass: {} // 根据滑动状态设置active的class类型
}
},
props: {
disabled: Boolean
},
methods: {
/**
* @function getOpenAmount
* @description
* 获取ion-item的开启值
* @return {number}
* */
getOpenAmount () {
return this.openAmount
},
/**
* @function getSlidingPercent
* @description
* 获取开口的百分比
* @return {number}
* */
getSlidingPercent () {
let openAmount = this.openAmount
if (openAmount > 0) {
return openAmount / this.optsWidthRightSide
} else if (openAmount < 0) {
return openAmount / this.optsWidthLeftSide
} else {
return 0
}
},
/**
* @function openLeftOptions
* @description
* 开启左边的选项卡
* @return {*} ins - 开启的组件示例的this,默认是当前组件自己的this
* */
openLeftOptions (ins = this) {
if (ins === this && this.leftOptions) {
if (this.state === SLIDING_STATE.Left || this.state === SLIDING_STATE.SwipeLeft) {
return
}
this.activeClass = {
'active-slide': true
}
this.$nextTick(() => {
if (this.optsDirty) {
this.calculateOptsWidth()
}
if (this.optsWidthLeftSide > 0) {
this.setOpenAmount(-this.optsWidthLeftSide, false)
}
})
}
},
/**
* @function openRightOptions
* @description
* 开启右边的选项卡
* @return {*} ins - 开启的组件示例的this,默认是当前组件自己的this
* */
openRightOptions (ins = this) {
if (ins === this && this.rightOptions) {
if (this.state === SLIDING_STATE.Right || this.state === SLIDING_STATE.SwipeRight) {
return
}
this.activeClass = {
'active-slide': true
}
this.$nextTick(() => {
if (this.optsDirty) {
this.calculateOptsWidth()
}
if (this.optsWidthRightSide > 0) {
this.setOpenAmount(this.optsWidthRightSide, false)
}
})
}
},
/**
* @function close
* @description
* 关闭当前的sliding
* */
close () {
// 关闭的条件:必须是开启状态,并且不是disable状态
if (Math.abs(this.openAmount) > 0 && this.state !== SLIDING_STATE.Disabled) {
this.setOpenAmount(0, true)
}
},
// ------------- @private -------------
/**
* 判断是否能拖动
* @private
* */
canDrag () {
if (this.disabled) return false
// 表示还在动画transition中
if (this.unregister) {
this.unregister()
this.unregister = null
}
return true
},
/**
* onDragStart
* @param {any} ev
* @return {*}
* @private
*/
onDragStart (ev) {
if (!this.canDrag()) return
this.firstCoord = pointerCoord(ev)
this.firstTimestamp = new Date().getTime()
this.startSliding(this.firstCoord.x)
this.$root && this.$root.$emit('onItemSlidingOpen', this)
this.isDraggingFromStart = true
},
/**
* onDragMove
* @param {any} ev
* @return {*}
* @private
*/
onDragMove (ev) {
if (!this.canDrag()) return
if (!this.isDraggingFromStart) return false
let coordX = pointerCoord(ev).x
// 判断当前的移动是滚动还是组件的左右移动
if (!this.isDraggingConfirm && Math.abs(coordX - this.firstCoord.x) > MAX_DELTAX) {
this.isDraggingConfirm = true
let directionCode = this.getDirection(this.firstCoord, pointerCoord(ev))
if (directionCode === DIRECTION_LEFT || directionCode === DIRECTION_RIGHT) {
this.isDragging = true
ev.preventDefault && ev.preventDefault()
} else {
this.isDragging = false
}
}
if (this.isDraggingConfirm && this.isDragging) {
this.moveSliding(coordX)
ev.preventDefault && ev.preventDefault()
}
},
/**
* onDragEnd
* @param {any} ev
* @private
*/
onDragEnd (ev) {
let coordX = pointerCoord(ev).x
let deltaX = (coordX - this.firstCoord.x)
let deltaT = (Date.now() - this.firstTimestamp)
if (deltaX === 0) {
this.close()
} else {
this.endSliding(deltaX / deltaT)
}
this.isDragging = false
this.isDraggingConfirm = false
this.isDraggingFromStart = false
if (this.openAmount === 0 && !this.unregister) {
// 如果点击结束后, 组件未开启, 且未动画, 则设为disabled状态
window.setTimeout(() => {
this.activeClass = {'active-slide': false}
}, 300)
this.state = SLIDING_STATE.Disabled
}
},
/**
* 获取方向
* @private
*/
getDirection (start, end) {
let deltaX = end.x - start.x
let deltaY = end.y - start.y
if (deltaX > 0 && Math.abs(deltaX) > Math.abs(deltaY)) {
return DIRECTION_RIGHT
}
if (deltaX < 0 && Math.abs(deltaX) > Math.abs(deltaY)) {
return DIRECTION_LEFT
}
if (deltaY > 0 && Math.abs(deltaX) < Math.abs(deltaY)) {
return DIRECTION_DOWN
}
if (deltaY < 0 && Math.abs(deltaX) < Math.abs(deltaY)) {
return DIRECTION_UP
}
return DIRECTION_NONE
},
/**
* 初始化:获取子组件实例,ItemSideFlags状态变更
* @private
* */
init () {
// 获取子组件实例
let side = 0 // ITEM_SIDE_FLAGS None=0
let componentIs = (component, name = '') => {
return component.$options && component.$options._componentTag && component.$options._componentTag.toLowerCase() === name.toLowerCase()
}
this.$children.forEach((item) => {
if (componentIs(item, 'item')) {
this.itemComponent = item
}
if (componentIs(item, 'ItemSlidingOptions') && item.side === 'left') {
this.leftOptions = item
side |= this.getSides(item)
}
if (componentIs(item, 'ItemSlidingOptions') && item.side === 'right') {
this.rightOptions = item
side |= this.getSides(item)
}
})
// ITEM_SIDE_FLAGS 的状态记录
this.sides = side // option的可滑动方向 none:0 left:1 right:2 both:3
this.optsDirty = true
// 事件注册
this.$root && this.$root.$on('onItemSlidingOpen', (ins) => {
if (this !== ins) {
this.close()
}
})
this.$root && this.$root.$on('onScroll', () => {
this.close()
})
},
/**
* 设置起始点,因为有原始点与options点
* @param {number} startX
* @private
* */
startSliding (startX) {
if (this.openAmount === 0) {
this.setState(SLIDING_STATE.Enabled)
}
this.startX = startX + this.openAmount
},
/**
* 计算openAmount,算入阻尼,最后执行item定位
* @param {number} x
* @return {number}
* @private
*/
moveSliding (x) {
if (this.optsDirty) {
this.calculateOptsWidth()
return 0
}
let openAmount = (this.startX - x)
switch (this.sides) {
case ITEM_SIDE_FLAGS.Right:
openAmount = Math.max(0, openAmount)
break
case ITEM_SIDE_FLAGS.Left:
openAmount = Math.min(0, openAmount)
break
case ITEM_SIDE_FLAGS.Both:
break
case ITEM_SIDE_FLAGS.None:
return 0
default:
console.assert(false, 'invalid ITEM_SIDE_FLAGS value')
break
}
if (openAmount > this.optsWidthRightSide) {
let optsWidth = this.optsWidthRightSide
openAmount = optsWidth + (openAmount - optsWidth) * ELASTIC_FACTOR
} else if (openAmount < -this.optsWidthLeftSide) {
let optsWidth = -this.optsWidthLeftSide
openAmount = optsWidth + (openAmount - optsWidth) * ELASTIC_FACTOR
}
this.setOpenAmount(openAmount, false)
return openAmount
},
/**
* 松手后的移动特性决定最终停在哪个位置
* @param {number} velocity 移动速度(惯性)
* @return {number}
* @private
*/
endSliding (velocity) {
// 松手后的停留点
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 isResetDirection = (this.openAmount > 0) === !(velocity < 0)
let isMovingFast = Math.abs(velocity) > 0.3
let isOnCloseZone = Math.abs(this.openAmount) < Math.abs(restingPoint / 2)
if (swipeShouldReset(isResetDirection, isMovingFast, isOnCloseZone)) {
restingPoint = 0
}
this.setOpenAmount(restingPoint, true)
this.fireSwipeEvent()
return restingPoint
},
/**
* 计算子组件的左右options的宽度
* @private
* */
calculateOptsWidth () {
if (!this.optsDirty) {
return
}
this.optsWidthRightSide = 0
if (this.rightOptions) {
this.optsWidthRightSide = this.rightOptions.width()
}
this.optsWidthLeftSide = 0
if (this.leftOptions) {
this.optsWidthLeftSide = this.leftOptions.width()
}
this.optsDirty = false
},
/**
* 获取options子组件的side属性对应的ItemSideFlags值
* @param {any} ins options子组件的实例
* @private
* */
getSides (ins) {
if (ins.side === 'left') {
return ITEM_SIDE_FLAGS.Left
} else {
return ITEM_SIDE_FLAGS.Right
}
},
/**
* 根据openAmount设置item的开闭位置
* @param {number} openAmount
* @param {boolean} isFinal - 是否关闭(随手拖动 还是 松手关闭)
* @private
* */
setOpenAmount (openAmount, isFinal) {
this.openAmount = openAmount
if (isFinal) {
// 松手关闭状态, 可以是到起始位置, 也可以是到中途点, 全部打回初始状态
this.isDragging = false
this.isDraggingConfirm = false
this.isDraggingFromStart = false
this.unregister && this.unregister()
if (openAmount === 0) {
// 动画过程禁止点击操作
this.unregister = transitionEnd(this.itemComponent.$el, () => {
this.setState(SLIDING_STATE.Disabled)
this.unregister = null
})
this.setItemTransformX(0)
} else {
this.unregister = transitionEnd(this.itemComponent.$el, () => {
this.unregister = null
})
this.setItemTransformX(-openAmount)
}
} else {
// 随手滚动阶段
if (openAmount > 0) {
let state = (openAmount >= (this.optsWidthRightSide + SWIPE_MARGIN))
? SLIDING_STATE.Right | SLIDING_STATE.SwipeRight
: SLIDING_STATE.Right
this.setState(state)
} else if (openAmount < 0) {
let state = (openAmount <= (-this.optsWidthLeftSide - SWIPE_MARGIN))
? SLIDING_STATE.Left | SLIDING_STATE.SwipeLeft
: SLIDING_STATE.Left
this.setState(state)
}
this.setItemTransformX(-openAmount)
/**
* @event component:ItemSliding#onDrag
* @description 正在拖动时触发
* @property {ItemSlidingComponent} this - 当前ItemSliding实例
*/
this.$emit('onDrag', this)
}
},
/**
* @param {String} state
* @private
* */
setState (state) {
if (state === this.state) return
this.timer && window.clearTimeout(this.timer)
this.activeClass = {
'active-slide': true,
'active-options-right': (state & SLIDING_STATE.Right),
'active-options-left': (state & SLIDING_STATE.Left),
'active-swipe-right': (state & SLIDING_STATE.SwipeRight),
'active-swipe-left': (state & SLIDING_STATE.SwipeLeft)
}
// bugFix
if (state === SLIDING_STATE.Disabled) {
this.timer = window.setTimeout(() => {
this.activeClass['active-slide'] = false
}, 16 * 5)
}
this.state = state
},
/**
* 设置子组件ion-item的transformX属性
* @param {number} val
* @private
* */
setItemTransformX (val = 0) {
if (this.itemComponent) {
window.requestAnimationFrame(() => {
this.itemComponent.$el.style.transform = `translate3d(${val >> 0}px, 0px, 0px)`
})
}
},
/**
* 对外发出swipe事件,
* @param {number} val
* @private
* */
fireSwipeEvent () {
if (this.state & SLIDING_STATE.SwipeRight) {
/**
* @event component:ItemSliding#onSwipe
* @description 当滑动超过一定阈值时触发, 这个事件不确定方向
* @property {ItemSlidingComponent} this - 当前ItemSliding实例
*/
/**
* @event component:ItemSliding#onSwipeLeft
* @description 向左滑动 超过按钮最大距离+SWIPE_MARGIN时触
* @property {ItemSlidingComponent} this - 当前ItemSliding实例
*/
this.$emit('onSwipe', this)
this.$emit('onSwipeLeft', this)
} else if (this.state & SLIDING_STATE.SwipeLeft) {
/**
* @event component:ItemSliding#onSwipeRight
* @description 向右滑动 超过按钮最大距离+SWIPE_MARGIN时触发
* @property {ItemSlidingComponent} this - 当前ItemSliding实例
*/
this.$emit('onSwipe', this)
this.$emit('onSwipeRight', this)
}
}
},
mounted () {
// 此时子组件才初始化完毕
this.init()
},
destroyed () {
this.$root && this.$root.$off('onItemSlidingOpen')
this.$root && this.$root.$off('onScroll')
}
}
/**
* 根据传入条件判断是否执行swipeReset
* @param {boolean} isResetDirection
* @param {boolean} isMovingFast
* @param {boolean} isOnResetZone
* @return {boolean}
* @private
*/
function swipeShouldReset (isResetDirection, isMovingFast, isOnResetZone) {
// The logic required to know when the sliding item should close (openAmount=0)
// depends on three booleans (isCloseDirection, isMovingFast, isOnCloseZone)
// and it ended up being too complicated to be written manually without errors
// so the truth table is attached below: (0=false, 1=true)
// isCloseDirection | isMovingFast | isOnCloseZone || shouldClose
// 0 | 0 | 0 || 0
// 0 | 0 | 1 || 1
// 0 | 1 | 0 || 0
// 0 | 1 | 1 || 0
// 1 | 0 | 0 || 0
// 1 | 0 | 1 || 1
// 1 | 1 | 0 || 1
// 1 | 1 | 1 || 1
// The resulting expression was generated by resolving the K-map (Karnaugh map):
let shouldClose = (!isMovingFast && isOnResetZone) || (isResetDirection && isMovingFast)
return shouldClose
}
</script>