🥲干货长文,如何用 uniapp 实现一个丝滑的英语学习卡盒小程序

659 阅读12分钟

前言

在前两周,我发了两篇文章介绍了我为什么做一个卡盒小程序以及这个小程序的一些功能和交互。这篇文章就讲讲这个小程序是用什么框架做的,数据如何存储,其中的一些动画效果如何实现,我在做的时候是怎么思考的。

技术栈

这个小程序我用的是 uniapp 写的,如果你不了解 uniapp 你可以理解为用 vue 的语法来写小程序,目前是支持 vue3 setup 的写法,基本和 web 端写 vue 没什么差异。

其次我选择了 unibest 作为开发框架,这个开发框架做的很好,其中包含了组件库 wot-design,样式库 unocss,请求库,图标库.代码格式化配置等等,默认的模板里配置好了一个基础的目录结构,我觉得和我以前的习惯还是很相符的。而且可以直接在 Vscode 进行 uniapp 开发,不用单独下载 HbuilderX ,真正开箱即用,基本可以省去一两天的基建搭建时间。

UI 设计

首先我的功能是很明确的,就是做一个简单的卡片工具,支持卡片正反面翻面记录内容。基于此我想着做的比较拟态一点,像真实的卡片放在卡盒里,所以有了首页的雏形。后续就是基于首页的样式去做卡盒的展开收起动画,大卡片模式的左右滑动查看,点击翻页等等。整体的风格是比较简约的,这样实现起来也比较简单。

temp.gif

个人的产品,如果 UI 能力不行就不做太多花里胡哨的,把一些间距排版做好,一打眼可以干净清爽,交互清晰自然就已经很不错了。

功能实现

数据的存储

我大部分数据和状态都使用 pinia 进行全局状态的存储,因为我的各个组件中都很频繁共享和编辑这些状态,用 pinia 就不用频繁在组件上加参数了。其次 unibest 中直接内置了 pinia 的小程序持久化库:

import { createPinia } from 'pinia'
import { createPersistedState } from 'pinia-plugin-persistedstate' // 数据持久化

const store = createPinia()
store.use(
  createPersistedState({
    storage: {
      getItem: uni.getStorageSync,
      setItem: uni.setStorageSync,
    },
  }),
)

export default store

使用 pinia 维护的状态都会直接存在小程序的存储中,退出小程序打开后还会保留,除非把小程序从个人小程序列表里删除了。

在我的项目中,我创建了三个 Store,分别是:

  • cardDataStore 维护所有的卡片数据
  • cardBoxStore 维护与卡盒相关状态和一些触发状态变更的方法
  • cardItemStore 维护与卡片相关状态和一些触发状态变更的方法

其实我的数据并不多,而且关联性很强,放在一个 Store 中也可以,但是我不想状态相关的变量和数据维护在一起,创建不同的 store 会把数据存储到小程序存储中不同的 key 里,考虑到未来数据可能会存储到云端,我只需要将 cardData 里的数据和云端做同步就好了,而且导出数据也会更方便。

image.png

这里关于状态的持久化在新版本中已经移除了,因为状态变更的比较多,如果有历史数据可能会相互影响,仅将卡片的数据做了持久化。

卡盒的折叠和展开

image.png

在卡盒折叠起来时,可以看到第一张卡片的封面是卡盒的名称和卡片数量,后面的卡片与第一张卡片有一点向右偏移,这个效果其实很容易实现的,你可以通过绝对布局先将全部卡片堆叠在一起,然后再通过绝对布局或 transfrom 为每一张卡片添加一点偏移,最后记得把卡片的层级根据顺序逐张递减。

实现的样式代码如下:

{
      position: 'absolute',
      top: '0px',
      transformStyle: 'preserve-3d',
      boxShadow: `rgba(0, 0, 0, 0.15) 3px 0px 6px -3px`,
      height: '200px',
      transform: `translate(${props.cardItemIndex * 8}px, 0px) scale(1) rotateY(0)`,
      transitionDelay: `${((props.cardItemIndex > 6 ? 6 : props.cardItemIndex) / 60).toFixed(3)}s`,
      zIndex: props.total - props.cardItemIndex,
    }

这里我选择了 transfrom 进行卡片的偏移,这里之所以没有选择使用绝对布局的 letf ,是因为性能问题,后面会提到。

实现了卡盒的折叠起来的样式,下一步是实现展开后的样式:

image.png

卡盒展开后是一行两列的布局,一眼看过去还挺简单的,我们可能会选择使用 Flex 布局来实现,但我们还需要考虑如何做卡片的动画,如果从绝对布局变成 Flex 布局是没办法加上过渡动画的,这样展开时就会很割裂,没有那种卡片移动到位的效果。因此我还是选择了继续使用绝对布局加 transform 去计算卡片展开后的位置。

实现的代码如下:

{
      position: 'absolute',
      top: '0px',
      transformStyle: 'preserve-3d',
      boxShadow: `rgba(0, 0, 0, 0.15) 3px 0px 6px -3px`,
      height: '200px',
      transform: `translate(${(props.cardItemIndex % 2) * 100}%, ${Math.floor(props.cardItemIndex / 2) * 200}px) scale(0.95) rotateY(0)`,
      transitionDelay: `${((props.cardItemIndex > 6 ? 6 : props.cardItemIndex) / 60).toFixed(3)}s`,
      transformOrigin: '50% 50%',
      zIndex: props.total - props.cardItemIndex,
    }

这里面最重要的这段代码:

translate(${(props.cardItemIndex % 2) * 100}%, ${Math.floor(props.cardItemIndex / 2) * 200}px)` 
  • ${(props.cardItemIndex % 2) * 100}%:它用于计算水平移动的距离。props.cardItemIndex % 2 的结果将是 0 或 1(取决于 props.cardItemIndex 是奇数还是偶数),然后乘以 100 得到百分比值。这意味着,对于偶数索引的元素,水平位移将是 0%(即不移动),而对于奇数索引的元素,水平位移将是 100%(即移动到其原始位置的右侧,相对于其包含块的宽度)。这里的百分比位移是基于元素自身的宽度计算的
  • ${Math.floor(props.cardItemIndex / 2) * 200}px:用于计算垂直移动的距离。Math.floor(props.cardItemIndex / 2) 将索引除以 2 并向下取整,然后乘以 200 得到像素值。这意味着,每两个元素将在垂直方向上相隔 200 px。

这里还有一个细节,就是当卡盒内部的卡片使用绝对布局时,高度是不会撑满容器的,因此外层容器的高度也得自行维护:

`${Math.ceil(props.data.cardItems.length / 2) * 200 + 44}px`

下一步就需要实现展开的动画效果了,css 中有一个很方便的属性 transition,他能帮我们在元素变化的时候自动加上过渡效果。

temp.gif

我们在前面的样式中加上 transition: 'all 0.3s' 就自动有动画效果了,过渡时间为 0.3s,这里提一下为什么我选择了 trnasform 做元素偏移,我最初使用的是 lefttop,但发现才几张卡片卡顿就很严重了,于是我就去找原因,后面才知道使用 transform 进行 transition 的动画效果会流畅很多。

  • transform :使用 transform 属性进行动画时,浏览器会利用 GPU 硬件加速来渲染动画效果。这通常会导致更高的动画性能和更流畅的视觉体验。Transform 属性(如 translate、scale、rotate 等)在动画过程中不会触发大量的重绘(repaint)和回流(reflow),因为这些操作是在 GPU 层面进行的,减少了主线程的负担。
  • absolute 布局:当使用 absolute 布局并通过改变 top、left 等属性来移动元素时,这会触发浏览器的重绘和回流,因为每次位置变化都需要重新计算布局和绘制元素。这种方式的动画性能通常低于使用 transform 属性,因为它更多地依赖于 CPU 的计算能力,而不是 GPU 的硬件加速。

使用 transform 后,一个有五六十张卡片的卡盒,打开时也不会卡顿,而且我们也可以将一些靠后的卡片直接移除 transition 效果,因为使用时根本就发现不了,只要前面几张丝滑的弹出效果就很好了,这也是我后续做性能优化的一个方向。

展开卡盒.gif

如果留心看图会发现,我的卡片展开和收起的时候会有一张一张回去的感觉,那是因为我根据卡片的顺序,给不同的卡片不同的动画执行事件,让动画更有层次一点,实现的方式也很简单,为每张卡片设置不同的 transitionDelay 属性即可

transitionDelay: `${((props.cardItemIndex >= 8 ? 8 : props.cardItemIndex) / 80).toFixed(3)}s`,

这段代码的作用是根据 props.cardItemIndex 的值动态计算一个过渡延迟时间(以秒为单位),值越大延迟越大,但是为了避免卡片太多的时候,后面的卡片要隔很久才执行动画,我设置了延迟的上限为 8 ,因为一屏幕最多也就展示 8 张卡片,设置大了也看不出来效果。

卡片翻面的实现

卡片的翻面实现比较简单,也是通过 transition 和 transfrom 来实现,核心代码是下面这行:

 transform: `rotateY(${isCardFlipped ? 180 : 0}deg)`,

但是要注意一个问题,就是假设卡片两面都有内容,你在翻面时动态切换的内容,如果你不做任何调整那么效果是这样的:

temp.gif

你会发现虽然卡片翻面了,但是内容没有翻面,因此我们需要在卡片翻面的时候,把里面的内容朝也翻面,里面的内容相当于转了 360 度,这样就正过来了。

卡片翻面.gif

滚动到指定容器的实现

在小程序中,有两个地方会滚动到指定位置,一个是展开卡盒的时候,即便你在页面的底部点击了某个卡盒,也会帮你滚动到当前卡盒的位置,不用自己手动划过去:

滚动到卡盒.gif

还有一个是在卡盒里添加卡片的时候,点击添加,不论当前位置在哪,都会滚动到能看到新卡片的位置:

滚动到卡片.gif

实现的代码是这样的:

import { getCurrentInstance } from 'vue'
const instance = getCurrentInstance()
const { safeAreaInsets } = uni.getWindowInfo()


// 滚动到卡盒位置
const scrollToCardBox = (position: 'top' | 'bottom' = 'top') => {
  const query = uni.createSelectorQuery().in(instance.proxy)
  query
    .select(`#card-box-${props.cardBoxIndex}`)
    .boundingClientRect((data) => {
      return data
    })
    .selectViewport()
    .scrollOffset((data) => {
      return data
    })
    .exec((data) => {
      uni.pageScrollTo({
        scrollTop:
          data[1].scrollTop +
          data[0].top -
          safeAreaInsets.top +
          (position === 'top' ? 0 : data[0].height),
        duration: 200,
      })
    })
}

首先,我们通过 getCurrentInstance 从 Vue 中获取了当前组件的实例,并将其存储在 instance 变量中。这个实例稍后会用于选择器查询的上下文。

接下来,定义了一个名为 scrollToCardBox 的函数,它有一个参数 position,这个参数可以是 'top'(默认)或 'bottom',用来决定我们要滚动到卡盒的哪个位置。

函数内部,我们使用 uni.createSelectorQuery() 创建了一个选择器查询,并通过 .in(instance.proxy) 指定了查询的上下文是当前组件的实例。

然后,我们做了两件事:

  1. 使用 .select 方法选择了具有特定 ID 的元素,并通过.boundingClientRect 获取了这个元素的位置和大小信息。
  2. 使用 .selectViewport 选择了视口,并通过.scrollOffset 获取了当前页面的滚动偏移量。

最后,通过.exec方法执行了这些查询,并在回调函数中处理了结果。回调函数的参数 data 是一个数组,包含了所有查询的结果。

在回调函数中,我们根据 position 参数的值计算了 scrollTop ,即要滚动到的目标位置。如果position'top',我们就滚动到卡盒的顶部;如果是 'bottom' ,我们就滚动到卡盒的底部(这时需要加上卡盒的高度)。

计算完 scrollTop 后,我们使用 uni.pageScrollTo 方法滚动到了这个目标位置,动画持续时间为 200 毫秒。safeAreaInsets.top 则表示不同设备安全区域的顶部高度。

注意点!

selecter 只能获取当前组件中具有这个选择器的元素,假设你引用了一个子组件,你想通过选择器选中子组件的元素是做不到的,你只能在子组件中直接使用 const query = uni.createSelectorQuery().in(instance.proxy) 才能获取成功。

拖拽排序的实现

卡片拖拽排序.gif

拖拽排序的实现还是比较复杂的,非常感谢这篇文章的作者 十九.uniapp之实现拖拽排序,给了我一个实现拖拽排序的思路,省了很多自己尝试的时间。我基于这篇文章的思路,进行了一些调整,下面给大家讲讲。

首先看看我的排序页面的元素结构:

<movable-area
  class="movable-area"
  :style="{
    height: list.length * itemHeight + 'px',
  }"
>
  <movable-view
    v-for="(item, index) in list"
    class="w-full bg-white px-4 flex items-center border-gray-2 border border-solid flex justify-between"
    :style="{
      height: itemHeight + 'px',
      zIndex: oldIndex === index ? 10 : 1,
    }"
    :key="item.id"
    :x="item.x"
    :y="item.y + 'px'"
    direction="vertical"
    @change="handleChange"
    out-of-bounds
    :disabled="!onSorting"
  >
    <view class="flex items-center gap-4">
      <view
        class="text-gray-4 text-lg p-2"
        @touchend="handleTouchEnd"
        @touchstart="handleDragStart(index)"
      >
        <text class="i-material-symbols-drag-indicator" />
      </view>
      // 内容
    </view>
  </movable-view>
</movable-area>

首先,拖拽需要用到小程序的两个原生组件 movable-viewmovable-areamovable-area指代可拖动的范围,在其中内嵌movable-view组件用于指示可拖动的区域。

可移动区域 (movable-area)

  • 通过 :style="{ height: list.length * itemHeight + 'px' }" 设置区域的高度,使其能够容纳所有列表项。

可移动的列表项 (movable-view)

  • 循环遍历 list 数组,为每个列表项创建一个 movable-view 元素。
  • 通过 :style="{ height: itemHeight + 'px' }" 设置每个列表项的高度。
  • :x:y 属性用于设置列表项的初始位置
  • direction="vertical" 指定只能垂直方向拖拽。
  • @change="handleChange" 监听拖拽过程中的位置变化,并触发 handleChange 函数。
  • out-of-bounds 属性允许元素超出可移动区域的边界。
  • :disabled="!onSorting"onSortingfalse 时,禁用拖拽功能。

实现拖拽排序核心是三个函数对应三个阶段,一是刚刚开始拖拽,二是拖拽的过程中,三是拖拽完成后松手。对应代码中的:

  • @touchstart="handleDragStart(index)"
  • @change="handleChange"
  • @touchend="handleTouchEnd"

那么我们先梳理一下我们需要用这三个函数做什么事情呢?

  1. 首先拖拽开始时,我们需要记录当前拖拽的元素,并且开始执行拖拽事件。
  2. 拖拽的过程中,被拖拽的元素它的位置是跟着我们的手指走的,所以我们不要去改变它的 y 值,免得拖着拖着元素跑了,其次如果我拖拽的这个元素,在拖拽过程中如果位置高于或低于其他元素,那么其他元素就应该往后排,把位置留给我当前的元素,从样式上来说就是要修改 y 的值,让元素向下偏移的更多一点,至于增加多少要用 index 来计算,
  3. 最后松手的时候,我得让我手指上的这个元素回到列表里距离它最近的那个位置。

明白了这个原理,我们来看实现的代码:

1. 数据准备

首先,代码获取了卡片列表数据 (list),并通过 initPosition 函数进行处理,为每个卡片添加 xy 属性,其中 y 属性决定了卡片的垂直排列位置。

const initPosition = (arr: any[]) => {
  return arr.map((item, index) => {
    return {
      ...item,
      y: index * itemHeight.value,
      x: 0,
    }
  })
}

const list = ref(_.cloneDeep(list)))

2. 拖拽开始

const handleDragStart = (index) => {
  currentIndex.value = index
  oldIndex.value = index
  onSorting.value = true
}

当触摸到拖拽手柄 (i-material-symbols-drag-indicator) 时,会触发 handleDragStart 函数。该函数记录下当前拖拽的卡片索引 (currentIndex) 和初始索引 (oldIndex),并开启拖拽状态 (onSorting.value = true)。

currentIndexoldIndex 的初始值一样,但时 currentIndex 会在拖拽的过程中根据当前位置进行更新,而 oldIndex 则记录的就是拖拽元素最初的索引位置。

3. 拖拽中

先看完整代码:

const handleChange = (e) => {
  if (e.detail.source !== 'touch' || !onSorting.value) {
    return
  }

  const { y } = e.detail
  const currentY = Math.floor((y + itemHeight.value / 2) / itemHeight.value)
  targetIndex.value = Math.min(currentY, list.value.length - 1)

  if (targetIndex.value !== currentIndex.value && targetIndex.value >= 0) {
    const newList = _.cloneDeep(list.value)
    const elementToMove = newList.splice(oldIndex.value, 1)[0]
    newList.splice(targetIndex.value, 0, elementToMove)

    list.value.forEach((item, index) => {
      // 当前项通过手动拖动已经到了指定位置,因此需要重新排序其他项的高度
      if (index !== oldIndex.value) {
        // 找到所有项在新数组中的位置
        const newItemIndex = newList.findIndex((newItem) => newItem.id === item.id)
        // 根据新数组的位置重新设置y值
        item.y = newItemIndex * itemHeight.value
      }
    })
    scrollList()
    nextTick(() => {
      currentIndex.value = targetIndex.value
    })
  }
}
  1. 检查事件来源

    	if (e.detail.source !== 'touch' || !onSorting.value) {
    	  return
    	}
    

这里首先检查触发这个事件的原因是不是因为触摸(即手指拖动)。如果不是触摸事件,或者当前的排序状态(onSorting.value)是关闭的,函数就直接返回,不做任何处理。

  1. 计算目标索引

    	const { y } = e.detail
    	const currentY = Math.floor((y + itemHeight.value / 2) / itemHeight.value)
    	targetIndex.value = Math.min(currentY, list.value.length - 1)
    

这里通过触摸事件的 y 坐标来计算当前拖动到的目标位置。首先,通过 y 加上列表项高度的一半(也就是每个元素的中间位置)后除以列表项的高度,得到一个索引值。接着确保这个索引值不会超出列表的最大长度。

  1. 检查是否需要移动

    	if (targetIndex.value !== currentIndex.value && targetIndex.value >= 0) {
    

如果目标索引和当前索引不同,并且目标索引是有效的(大于等于0),那么就需要移动列表项。

  1. 移动列表项

    	const newList = _.cloneDeep(list.value)
    	const elementToMove = newList.splice(oldIndex.value, 1)[0]
    	newList.splice(targetIndex.value, 0, elementToMove)
    

这里首先深拷贝了当前的列表(为了避免直接修改原列表),然后使用 splice 方法从原列表中移除要移动的项,并将其添加到新列表的目标位置。

  1. 更新其他项的y值

    	list.value.forEach((item, index) => {
    	  if (index !== oldIndex.value) {
    	    const newItemIndex = newList.findIndex((newItem) => newItem.id === item.id)
    	    item.y = newItemIndex * itemHeight.value
    	  }
    	})
    

遍历原列表,对于除了我们手指上的这个元素以外的每个项,找到它们在新列表中的位置,并根据这个新位置更新它们的 y 坐标值。

  1. 刷新列表显示

    	scrollList() // 滚动的实现
    	nextTick(() => {
    	  currentIndex.value = targetIndex.value
    	})
    

这里调用的 scrollList 函数后面单独介绍,时确保拖动后的新顺序能够立即反映在界面上。然后,使用nextTick确保在 DOM 更新后,将当前索引更新为目标索引。

在拖拽过程中,handleChange 函数会不断监听手指移动的 y 轴坐标,并计算出当前拖拽到的目标索引 (targetIndex)。

4. 拖拽结束

当手指松开时,handleTouchEnd 函数会进行排序操作。

const handleTouchEnd = () => {
  if (!onSorting.value) return
  onSorting.value = false
  list.value[oldIndex.value].y = targetIndex.value * itemHeight.value
  list.value = initPosition(list.value.sort((item1, item2) => item1.y - item2.y))
  oldIndex.value = -1
  currentIndex.value = -1
  targetIndex.value = -1
  scrollList()
}
  1. 首先判断是否处于拖拽状态 (onSorting.value)。
  2. 接着将被拖拽卡片的最终 y 值更新为目标索引 (targetIndex.value) 乘以卡片高度 (itemHeight.value)。
  3. 使用 initPosition 函数重新排序 list 数据,排序依据是卡片的 y 值,这一步也就是将手指上的这项元素和所有的元素再一起重新排序一次,就可以把元素归位了。
  4. 重置所有拖拽相关索引 (oldIndex, currentIndex, targetIndex)。
  5. 调用 scrollList 函数,根据目标索引调整滚动条位置,确保目标卡片可见。

通过以上步骤,实现了拖拽卡片并进行排序的功能。用户可以直观地通过拖拽的方式调整卡片的顺序,并且在排序完成后将信息保存到数据存储中。

拖拽的过程中增加滚动

前面的步骤实现后,你会发现如果元素拖动到容器边缘的时候,容器不会自动往下滚。

temp.gif

因此我们需要再补充实现一个滚动的逻辑,常见的做法是把元素拖动到滚动容器边缘的时候,容器会自动往下滚动,我们手指上的这个元素不动,这样就实现了拖动到屏幕外元素的效果。

但在小程序中有一个问题,就是我们手指上的这个元素,在滚动的时候也会跟着滚动,当我们按照上述方式实现时,滚动到边缘时,我们手指上这样元素就会往上面弹一下,操作体验不太好,因此我采用了另一种方式。

先看代码:

const scrollTop = ref(0)

onMounted(() => {
  scrollTop.value = 0
})

const scrollList = _.throttle(
  () => {
    const middleIndex = (maxItems - 1) / 2
    if (targetIndex.value > middleIndex) {
      scrollTop.value = (targetIndex.value - middleIndex) * itemHeight.value
    } else {
      scrollTop.value = 0
    }
  },
  50,
  {
    leading: true,
  },
)

这里,我们计算了一个名为 middleIndex 的变量,它表示列表中间项的索引。maxItems 是列表中的总项数,然后除以2得到中间位置。

if (targetIndex.value > middleIndex) {
  scrollTop.value = (targetIndex.value - middleIndex) * itemHeight.value
} else {
  scrollTop.value = 0
}

然后滚动过程中,我们判断当前元素是不是已经超出了屏幕中间那个元素的位置,如果超过了,我们就往屏幕下面滚动一项,否则说明我们还在处于屏幕不需要任何滚动就能拖拽的位置,滚动高度就为0,这样我们手指上的元素滚动到中间往下时,容器就会开始滚动了。

要注意的是,这种实现方式同样也会因容器的滚动而导致拖拽的元素和手指错位,因此我在最新版本中,将每个元素的高度调小一点,尽量减少错位的距离,让自动滚动的距离小一点,且一屏能够放在更多的元素,避免频繁的滚动,但在很长距离的拖动时还是会有些问题,这个不知道大家有没有更好的解决方案。

拖拽排序.gif

如何实现滚动锁定

在底部弹出的弹窗中,如果你只有两个选项,内层不会滚动的时候,就会造成滚动穿透,也就是在弹窗里面滚动背后的容器,这时候就需要在弹出弹窗时进行滚动锁定。

在 wot design 的文档中有提到,小程序提供了 page-meta 属性,可以用于动态调整页面整体是否溢出

<!-- page-meta 只能是页面内的第一个节点 --> <page-meta :page-style="`overflow:${show10 ? 'hidden' : 'visible'};`"></page-meta> 

<wd-popup v-model="show10" lock-scroll position="bottom" :safe-area-inset-bottom="true" custom-style="height: 200px;" @close="handleClose10"></wd-popup>

如果你用的是 unibest ,那你要记得将页面的 layout 设置为 false,否则 page 页面会被 layout 容器包住,page-meta 将无法作为页面中第一个节点。在 unibest 中我们不需要修改 page.json

<route lang="json5" type="home">
{
  style: {
    navigationStyle: 'custom',
    navigationBarTitleText: '首页',
  },
++  layout: false,
}
</route>

轮播图使用注意点

关于轮播图的使用,因为比较简单这里不单独介绍,但是我要说一个容易踩坑的地方,就是如果你在 uniapp 中使用大驼峰的形式去使用组件,例如轮播图中的 swiper-item 组件写成 SwiperItem,刚开始你会发现没问题,可以正常渲染,但是传入属性的时候,有些属性就会失效。

例如轮播图中的 current 属性,用于指定当前展示的轮播图,我在点击卡片进入轮播图的时候需要动态设置这个值,当我组件使用 SwiperItem 的写法时如何设置都没用,最初我也没有怀疑是这个问题,自己折腾了好久,查了很多关于小程序或者 uniapp 赋值失效的问题,最终还是误打误撞才改发现这个问题的,所以我推荐在 uniapp 中所有组件都使用 kebab-case(小写短横线命名法),避免一些特殊问题。

交互的实感

这一点是我在调研其他卡盒 APP 的时候意识到的,有些 APP 的按钮很小,点击后没有任何反馈效果,而且有些操作还是有延迟的,那么我就会怀疑自己是否点击成功,从而重复点击某个按钮,这种体验就像在使用一个老旧的遥控器控制电视时,你按下按钮,但电视没有立即切换频道或调整音量,遥控器上也没有灯光或其他反馈来告诉你操作是否成功。这时,你可能会因为不确定而多次按下同一个按钮,结果却是频道切换过头或音量变得过大。

image.png

为了避免这个问题,我从两个角度去解决,一个是视觉层面,一个是触觉层面。

按钮的触摸效果

在视觉层面,我通过给可以操作的按钮都加了 hover 效果,要注意的是小程序中添加 hover 效果的方式和 web 端不太一样:

<view
    @tap="onCloseBtnTap"
    :class="[
      closeCardBoxBtnClass,
      'flex-1 rounded-none border-gray-2  border-r border-r-solid transform-origin-center',
    ]"
+    hoverClass="!scale-97 transform-origin-center"
+    :hoverStartTime="0"
+    :hoverStayTime="200"
  >
    <view class="i-carbon-collapse-all"></view>
    <view>收起卡盒</view>
  </view>
  • hoverClass:这是一个类名,用于在小程序中定义手指悬停时元素的样式
    • scale-97:元素在鼠标手指时会缩放到原始大小的 97%。这是一种常见的视觉效果,用来表示元素被激活或被选中。
    • transform-origin-center:这指定了变换的原点,即缩放(scale)操作的中心点。center 表示变换的中心是元素的中心点。:
  • hoverStartTime:这个参数定义了手指悬停在元素上时,触发样式变化的起始时间。在这里,0 表示没有延迟,即手指一悬停立即触发样式变化。
  • hoverStayTime:这个参数定义了手指离开悬停元素上时,样式保持变化状态的时间。在这里,200 表示样式变化会持续 200 毫秒。

效果如下图:

点击按钮.gif

交互时的震动

除了视觉的交互效果,我的很多卡片状态切换,例如打开卡盒,进入大卡片模式,进入编辑状态都会触发一个很轻的震动效果,加强执行操作的实感。这一点很容易实现,直接用 uniapp 中封装的触发震动的函数:

const openCardBox = (cardBoxId: string) => {
+  uni.vibrateShort({ type: 'soft' })
  state.openCardBoxId = [...state.openCardBoxId, cardBoxId]
}

震动的长短强弱可以通过调用其他 API 或传入不同参数自行调节,但是我建议不要太强烈了,而且也不要太频繁,只有一些重要的交互加上,免得手机总是嗡嗡的,尤其一些震动马达不太好的手机。

未来计划

在最新版的小程序中,我已经添加了朗读卡片的功能,并且 UI 也做了新一轮的优化,后续还会继续出文章介绍一些小功能点的更新和实现,欢迎大家关注。目前小程序已经上线,小程序名称为 “学习卡盒”,紫色图标那个,已经在通过认证了,欢迎大家搜索体验。