用 Vue3 写一个 swipe-cell

3,426 阅读2分钟

前言

最近升级了下 vue-cli,发现已经能够创建 Vue 3 的项目了,为了跟进最近技术,决定开发一个swipe-cell组件来学习一下如何使用 Vue 3。

创建项目

目前官方有两种脚手架供我们生成项目,分别是原来的 vue-cli 和 尤大新开发的 vite。我这边是使用 vue-cli 进行创建的,vite 等后面有时间再来研究一下。

将 vue-cli 升级到最新版本,然后使用vue create 项目名创建一个 Vue 3 的项目。

然后选择使用 3.x 版本就行了。

组件编写

需求分析

swipe-cell组件主要是对于touch相关事件的相关操作进行监听来实现滑动的,所以基本操作只需要在内部定义响应的事件进行滚动监听就好。然后用户再点击到非当前滑动区块时将之前已经滑动的区块重置,并且点击其他模块进行响应的操作可以单独提出进行封装。

代码实现

以下代码都是使用 typescript 编写

下面是 setup()内的基本实现逻辑:

const state = reactive({
  start: 0,
  current: 0,
  opening: false,
  swiping: false
})

// 右侧的隐藏按钮
const actionBtns = ref<HTMLDivElement | null>(null)

// 移动距离
const translateStyle = computed(() => {
  return {
    transform: `translateX(${state.current}px)`
  }
})
// 按钮的宽度
const actionsWidth = computed(() => {
  return Math.ceil(actionBtns.value?.getBoundingClientRect().width || 0)
})
// 监听 swiping 和 opening 的变化
watchEffect(() => {
  // 如果 state.opening 放前面 touchEnd 结束将不会触发 else 分支
  if (!state.swiping && state.opening) {

    state.current = -actionsWidth.value
  } else {
    state.current = 0
  }
})

const touchStart = (e: TouchEvent) => {
 // 开始滑动,记录位置
  state.start = e.changedTouches[0].clientX
  // 如果已经打开,直接关闭
  if (state.opening) {
    state.opening = false
  }
}
const touchMove = (e: TouchEvent) => {
  const current = e.targetTouches[0].clientX
  const scale = current - state.start
  // 组件只进行左滑,所以往右不进行操作
  if (scale > 0 || state.opening) {
    return
  }
  const swipeScale =
        Math.abs(scale) < actionsWidth.value ? scale : -actionsWidth.value
  state.swiping = true
  state.current = swipeScale
}
const touchEnd = () => {
  // 如果滑动距离大于一半就直接全部打开
  if (Math.abs(state.current) >= actionsWidth.value / 2) {
    state.opening = true
  }
  state.swiping = false
}
// 默认有个删除按钮,所以可绑定 delete 事件
const handleDelete = (e: MouseEvent) => {
  emit('delete', e)
}
return {
  actionBtns,
  translateStyle,
  touchStart,
  touchMove,
  touchEnd.
  handleDelete
}

然后就是对点击其他位置时的情况作判断,这里我们单独提出来封装,下面的代码借鉴了 react 的 ahooks

// ./utils/useClickAway.ts
import { onMounted, Ref, onUnmounted } from 'vue'

const defaultEvent = 'click'
type EventType = MouseEvent | TouchEvent

const useClickAway = (
  onClickAway: (e: EventType) => void,
  target: Ref<HTMLElement | null>,
  eventName = defaultEvent
) => {
  const handler = (event: Event) => {
    if (!target.value || target.value.contains(event?.target as Node)) {
      return
    }
    onClickAway(event as EventType)
  }

  onMounted(() => {
    document.addEventListener(eventName, handler)
  })
  onUnmounted(() => {
    document.removeEventListener(eventName, handler)
  })
}

export default useClickAway

最后再加入到组件中:

// ...
// 最外层的 swipe-cell,用于判断是否点击到组件外部
const swipeCell = ref<HTMLDivElement | null>(null)

// 如果点击到了其他位置就恢复到原位
useClickAway(
    () => {
      state.opening = false
    },
    swipeCell,
    'touchstart'
)
// ...
return {
  swipeCell
  //...
}

全部组件代码

<template>
  <div class="swipe-cell-container" ref="swipeCell">
    <div
      class="cell-wraper"
      :style="translateStyle"
      @touchstart.passive="touchStart"
      @touchmove.passive="touchMove"
      @touchEnd.passive="touchEnd"
    >
      <div class="cell-content">
        <slot>
          <span>{{ content }}</span>
        </slot>
      </div>
      <div class="cell-actions" ref="actionBtns" @touchstart.stop.passive>
        <slot name="actions">
          <button @click="handleDelete" class="delete-btn">
            {{ delText }}
          </button>
        </slot>
      </div>
    </div>
  </div>
</template>
<script lang="ts">
import { defineComponent, ref, reactive, computed, watchEffect } from 'vue'
import useClickAway from './utils/useClickAway'
interface SwipeCellProps {
  content: string
  delText: string
}
export default defineComponent({
  name: 'SwipeCell',
  props: {
    content: {
      type: String,
      default: ''
    },
    delText: {
      type: String,
      default: '删除'
    }
  },
  setup(props: SwipeCellProps, { emit }) {
    const state = reactive({
      start: 0,
      current: 0,
      opening: false,
      swiping: false
    })
    const swipeCell = ref<HTMLDivElement | null>(null)
    const actionBtns = ref<HTMLDivElement | null>(null)
    const translateStyle = computed(() => {
      return {
        transform: `translateX(${state.current}px)`
      }
    })
    const actionsWidth = computed(() => {
      return Math.ceil(actionBtns.value?.getBoundingClientRect().width || 0)
    })
    watchEffect(() => {
      // 如果 state.opening 放前面 touchEnd 结束将不会触发 else 分支
      if (!state.swiping && state.opening) {
        state.current = -actionsWidth.value
      } else {
        state.current = 0
      }
    })
    useClickAway(
      () => {
        state.opening = false
      },
      swipeCell,
      'touchstart'
    )
    const touchStart = (e: TouchEvent) => {
      state.start = e.changedTouches[0].clientX
      if (state.opening) {
        state.opening = false
      }
    }
    const touchMove = (e: TouchEvent) => {
      const current = e.targetTouches[0].clientX
      const scale = current - state.start
      if (scale > 0 || state.opening) {
        return
      }
      const swipeScale =
        Math.abs(scale) < actionsWidth.value ? scale : -actionsWidth.value
      state.swiping = true
      state.current = swipeScale
    }
    const touchEnd = () => {
      if (Math.abs(state.current) >= actionsWidth.value / 2) {
        state.opening = true
      }
      state.swiping = false
    }
    const handleDelete = (e: MouseEvent) => {
      emit('delete', e)
    }
    return {
      swipeCell,
      actionBtns,
      translateStyle,
      handleDelete,
      touchStart,
      touchMove,
      touchEnd
    }
  }
})
</script>
<style lang="less" scoped>
.swipe-cell-container {
  border-bottom: 1px solid #eee;
  width: 100%;
  overflow: hidden;
  .cell-wraper {
    display: flex;
    position: relative;
    transform: translateX(0);
    transition: transform 0.2s linear;
    width: 100%;
    .cell-content {
      width: 100%;
    }
    .cell-actions {
      display: flex;
      align-items: center;
      justify-content: center;
      height: 100%;
      position: absolute;
      right: 0;
      top: 0;
      transform: translateX(100%);
      .delete-btn {
        height: 100%;
        font-size: 14px;
        color: #eeeeee;
        padding: 0px 24px;
        border: none;
        outline: none;
        background: hsl(348, 100%, 61%);
      }
    }
  }
}
</style>

到这里,一个简单的swipe-cell组件就完成了:

发布成库

编写完组件代码后,我们尝试将其作为 Vue 的插件使用,并发布到 npm 上。首先在组件的文件同级新建index.ts文件:

import { Plugin } from 'vue'
import SwipeCell from './index.vue'

export { SwipeCell }

export default {
  install: (app, options) => {
    app.component(options?.name || 'SwipeCell', SwipeCell)
  }
} as Plugin

然后手动编写类型声明文件:

// ./types/swipe-cell.d.ts
import { Plugin } from 'vue'

export interface SwipeCellProps {
  content: string
  delText: string
}
declare class SwipeCell {
  $props: SwipeCellProps
}
declare const _default: Plugin
export { SwipeCell }

export default _default

最后再根据 vue-cli 对配置打包就可以发布成库了,具体的发布细节这里就不再赘述了。

结语

本文只使用了部分 Vue3 的 Api,算是对于 Vue3 的一次尝鲜。文中如果有什么不足之处,还请各位大佬及时指出。

全部代码目前已提交到 github

本文使用 mdnice 排版