前言
我们知道 H5 可视区域相对较小,为了将内容更多的呈现,许多需求都会将 列表数据项 的操作,选择以对数据项左滑形式出现。这类交互常见于商品购物车页面,左滑出现删除等操作(美团、京东 等平台)。
最近刚好遇到这样一个交互需求,便以文章的形式将实现原理记录下来。
一、实现分析
从需求交互来看,列表数据项 由两部分组成:左侧 内容区域 和 右侧 操作区域。
默认可视区域仅展示内容,右侧的操作区域被隐藏,这里可以使用 定位负值 来实现布局。
经过分析我们得出 DOM 的结构如下:
html
<div class="slide-operate">
<!-- 滑动块 -->
<div class="slide-operate-content">
<!-- 实际内容,作为组件使用时,表示 vue slot 或者 react children -->
<div class="content-slot">滑块内容</div>
</div>
<!-- 滑动之后出现的操作按钮 -->
<div class="slide-operate-btns">
<button>删除</button>
</div>
</div>
其中 .slide-operate-content
表示内容区域,可以根据需求来自定义内容;.slide-operate-btns
则表示操作区域,比如有删除操作。
在样式布局上,我们需要为 .slide-operate-btns
操作区域 定位成负值,结合 .slide-operate
容器设置 overflow: hidden
进行隐藏。
css
.slide-operate{
position: relative;
overflow: hidden;
}
.slide-operate-btns{
position: absolute;
top: 0;
/* 按钮最初为隐藏,right 使用负值,具体值根据 按钮数量 及 单个按钮宽度计算 */
/* right: -50px;
width: 50px; */
height: 100%;
display: flex;
justify-content: space-between;
align-items: center;
}
// 假设这是实际内容
.content-slot{
background-color: aquamarine;
border-radius: 5px;
height: 50px;
}
由于在不同场景下,操作项的个数及单个按钮宽度会存在不一致,建议实际的操作区域宽度由外部传入,并以 JS 方式为其设置 right
和 width
。
假设我们的场景中只有一个删除操作,给到的宽度为 50px,这里使用 distance
变量表示。(这个值也将作为滑动距离的参照值)
js
const sildeOperateContent = document.querySelector(".slide-operate-content");
const sildeOperateBtns = document.querySelector(".slide-operate-btns");
// 假设右侧按钮容器的宽度为 50,也是向左滑动的距离。如果是封装组件,这个变量将由外部传入
const distance = 50;
sildeOperateBtns.style.setProperty("width", `${distance}px`); // 按钮的整体宽度
sildeOperateBtns.style.setProperty("right", `-${distance}px`); // 最初 right 位置
现在初版布局完成了,接下来要让内容向左动起来,包含两个部分:元素位置移动(CSS transform-translate) 和 触发移动行为(JS touch 事件)。
让元素位置发生移动类似于如下代码:
css
sildeOperateContent.style.cssText = `transform: translate(${-distance}px, 0)`;
sildeOperateBtns.style.cssText = `transform: translate(${-distance}px, 0)`;
而触发用户滑动行为,可以使用 H5 的 touch 相关事件。
二、滑动实现
1. 定义状态变量
在滑动过程中,一定会涉及从手指按下时到移动过程中的位置计算,这里我们定义 state
来记录这些信息:
js
let state = {
startX: 0, // 记录滑动起始位置,手指触碰到屏幕时 x 轴的位置
moveDistance: 0, // 记录手指移动距离加上滑动阻力后的移动距离
}
2. touch 事件处理
我们会用到 touchstart
、touchmove
和 touchend
三个事件。
touchstart
:每次按下时都会初始化 state 状态,并记录上start.startX
按下时的位置;touchmove
:每次移动时都会根据 最新触摸位置move
与start.startX
进行计算,得出滑动的距离state.moveDistance
;touchend
:当手指离开屏幕后,我们要根据滑动的距离(state.moveDistance
),和 右侧操作区域 的宽度(distance
)进行比较,来确定是否展示 操作按钮。
具体实现如下:
js
sildeOperateContent.addEventListener("touchstart", event => {
state.moveDistance = 0;
state.startX = event.targetTouches[0].clientX;
});
sildeOperateContent.addEventListener("touchmove", event => {
const move = state.startX - event.targetTouches[0].clientX; // 获取左滑的距离
// move 大于 0,说明手指向左移动了
if (move > 0) {
event.preventDefault();
// 增加滑动阻力,避免 上下滑动 意外 触发 左右滑动 逻辑
state.moveDistance = Math.pow(move, 0.9); // Math.pow 求一个数的 n 幂次方,这里 n 用 0.9 控制滑动阻力。
// 控制滑动的最大距离为按钮的宽度
state.moveDistance = Math.min(state.moveDistance, distance);
}
});
sildeOperateContent.addEventListener("touchend", () => {
// 如果滑动结束 滑动距离大于右侧按钮的一半 则出现按钮,否则隐藏按钮
if (state.moveDistance > distance / 2) {
state.moveDistance = distance;
} else {
// 若滑动距离没有超过按钮的一半,回到初始位置
state.moveDistance = 0;
}
});
3. 实现元素位置移动
现在,我们通过 touch 事件拿到了要滑动的距离,由于我们采用原生 JS 来实现,并没有框架的数据驱动视图更新,我们需要手动操作 DOM 来实现元素位置移动。
为了避免在多处编写 DOM 移动操作,这里采用 Proxy
方式对 state.moveDistance
的 set
变化进行监听,实现视图更新。
最终,我们将 state
改造为如下形式。
js
let defaultState = {
startX: 0, // 记录滑动起始位置,手指触碰到屏幕时 x 轴的位置
moveDistance: 0, // 记录手指移动距离加上滑动阻力后的移动距离
}
// Proxy 观察滑动距离的变化,从而更新视图
const state = new Proxy(defaultState, {
set(target, key, value) {
const oldValue = target[key];
if (key === "moveDistance" && oldValue !== value) {
value = Math.floor(value);
sildeOperateContent.style.cssText = `transition: 300ms; transform: translate(${-value}px, 0)`;
sildeOperateBtns.style.cssText = `${sildeOperateBtns.style.cssText}; transition: 300ms; transform: translate(${-value}px, 0)`;
}
target[key] = value;
return true;
}
});
4. 优化交互
当处于 展示操作项 状态时,我们期望再次滑动内容时,仅是恢复为最初隐藏 操作项 的状态,不希望在这个场景下允许滑动,可以设定一个 state.stop
来限制。
js
let defaultState = {
...
// 新增交互优化
stop: false, // 若现在操作按钮处于显示状态,再次进行滑动时让其恢复默认状态,不去改变滑动位置。
}
sildeOperateContent.addEventListener("touchstart", event => {
if (state.moveDistance === distance) {
state.stop = true; // 按下时记录状态
}
...
});
sildeOperateContent.addEventListener("touchmove", event => {
if (state.stop) return; // 移动时判断状态
...
});
sildeOperateContent.addEventListener("touchend", () => {
...
state.stop = false; // 松开时重置状态
});
最终效果图如下:
滑动前:
滑动后: