仿抖音短视频组件实现方案
本文将基于
Taro
实现一个和抖音一样的滑动切换视频的组件,话不多说,直接进入正文
# 需求分析
提示
通过分析抖音的交互可以知道,我们需要实现一个上滑下滑切换视频的功能,当处于第一个视频的时候下滑需要有一个阻尼效果,但不能真的切换,上滑则是正常的切换下一个视频。交互效果类似swiper
,也就是说我们需要先实现一个swiper
组件
# 技术选型
提示
要实现swiper
那种效果,首先能想到的就是可不可以就用swiper
来实现。一开始我用swiper
实现了一版,在写的过程中遇到了大概如下几个问题:
- 问题1:需求中提到处于第一个视频时下滑不可以切换视频并且要以阻尼效果代替,上滑则是正常的选项卡切换
- 问题2:考虑性能优化的问题,
swiper
不能渲染真实视频的数量,我们需要去维护一个虚拟列表,小程序建议一个页面视频不多于3
个,也就是说我们需要维护一个3
项的swiper
在实现的过程中,首先我考虑到数据的问题,采用切割原数组的方式只渲染3
项swiper
,然后通过swiper
组件的circular(衔接滑动)
来切换选项卡,但这时出现了一个问题,就是当处于第一个选项卡的时候,下滑会切换到最后一个选项卡。这显然是不满足需求的,
于是我想到是否可以通过动态设置circular
的值来规避这个问题,当处于第一项时circular
设置为false
,非第一项时设置为true
。实践下来发现动态设置circular
的值会导致值变化的那一项的过渡动画丢失。后续尝试了很多种奇淫技巧都没法很好的解决这个问题,所以最终还是pass了用swiper
的方案。
最终决定通过手写动画效果的方式来实现
# 开始
首先我们完成一下组件的雏形
import { View } from "@tarojs/components"
import { useRef, useState, useMemo } from "react";
import "./index.scss";
export default function VideoSwiper({ swiperList }) {
const [curIdx, setCurIdx] = useState(0); // 当前播放的视频索引
// 只渲染当前项及其前后各一项
const visibleItems = useMemo(() => {
const start = Math.max(0, curIdx - 1);
const end = Math.min(swiperList.length, curIdx + 2);
return swiperList.slice(start, end).map((item, index) => ({
data: item,
virtualIndex: start + index
}));
}, [swiperList, curIdx]);
return (
<View className="custom-swiper">
<View
className="swiper-content"
>
{visibleItems.map((item) => (
<View
key={item.virtualIndex}
className='swiper-item'
>
{item.data}
</View>
))}
</View>
</View>
)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
交互效果先不管,首先我们需要实现一个虚拟列表
,也就是可视区域渲染的数组visibleItems
。
这里主要是通过当前播放的索引来填充他前后各一项的数据,没有太多说的。
这里注意一下最终生成的数据里有一个virtualIndex
属性,他的作用是保存当前项在原始数据中的索引,后面会用到。
接下来就开始写交互了,经过分析之后可以知道我们需要通过用户的手势来判断是上滑还是下滑,以及滑动结束后去切换视频。 所以会涉及到以下三个事件:
onTouchStart
:触摸动作开始。onTouchMove
:触摸后移动。onTouchEnd
:触摸动作结束。
判断上滑还是下滑主要是通过在onTouchStart
的时候记录下来当前位置,然后在onTouchMove
移动的过程中去判断移动值和按下时的值的差别。
同时我们还需要在手指移动的过程中同步去更新视图的偏移量来达到视图跟着手指动的效果
// 手指按下后移动的距离
const [translateY, setTranslateY] = useState(0);
// 手指按下时的坐标Y
const touchStartY = useRef(0);
// 手指移动之后的坐标Y
const touchMoveY = useRef(0);
/**
* 记录手指按下时的位置
* @param e
*/
const handleTouchStart = (e: any) => {
touchStartY.current = e.touches[0].clientY;
};
/**
* 记录手指移动时的位置
* @param e
*/
const handleTouchMove = (e: any) => {
e.preventDefault?.(); // 阻止默认滚动
e.stopPropagation?.();
touchMoveY.current = e.touches[0].clientY;
const distance = touchMoveY.current - touchStartY.current;
if (curIdx === 0 && distance > 0) {
setTranslateY(distance * 0.3);
return;
}
setTranslateY(distance);
};
/**
* 手指离开屏幕后
*/
const handleTouchEnd = () => {
const distance = touchMoveY.current - touchStartY.current;
if (Math.abs(distance) > 100) {
if (distance > 0 && curIdx > 0) {
setCurIdx(curIdx - 1);
} else if (distance < 0 && curIdx < swiperList.length - 1) {
setCurIdx(curIdx + 1);
}
}
setTranslateY(0);
};
// 计算当前项的位置
const basePosition = useMemo(() => {
return -curIdx * 100;
}, [curIdx]);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
这里面有几个注意点:
- 当手指在移动时我们要实时保存移动之后的位置,并且根据这个位置计算出手指移动了多少,然后赋值给
translateY
,试图将会根据这个变量一起偏移,这样就实现了试图跟着手指动的效果 - 在移动式需要判断一下是否处于第一个选项卡,是的话要去实现一个阻尼效果,也就是
setTranslateY(distance * 0.3)
这一段,设置一个更小的便宜量来达到阻尼的效果 - 在手指离开屏幕后,根据偏移量是否
大于100
来判断是否需要切换视频,并且将translateY
置为0
,到此整个动画就结束了 - 以上3个过程我们只是在模拟试图跟着手指滑动而移动,但还没有真正的切换整个视图。所以这里还需要去计算出切换整个视图需要偏移多少。
下面看一下html代码
return (
<View className="custom-swiper">
<View
className="swiper-content"
style={{
height: `${swiperList.length * 100}vh`,
transform: `translateY(calc(${basePosition}vh + ${translateY}px))`,
transition: translateY === 0 ? 'transform 0.3s' : 'none'
}}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{visibleItems.map((item) => (
<View
key={item.virtualIndex}
className='swiper-item'
style={{
background: item.data,
transform: `translateY(${(item.virtualIndex) * 100}%)`
}}
>
{item.data}
</View>
))}
</View>
</View>
)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
也是提一下几个注意点
- 渲染的真实选项会一一排列,他们的位置就是根据
virtualIndex
来计算的,比如第三项的偏移量就是300vh
,第四项的就是400vh
。这也就是为什么生成数据的时候要保留virtualIndex
的原因 swiper-content
这个盒子的总高度就是所有选项卡渲染上去的真实总高度,我们只是在滑动的过程中一直去移动实际渲染的视图,整个就是一个虚拟列表的实现过程,不清楚的可以回顾之前的虚拟列表文章- 手指滑动过程中其实就是我们在移动
swiper-content
这个盒子,他的偏移量就是上文中的basePosition + 当前translateY
,移动的过程中translateY
会不断的改变,所以整个视图会跟着手指移动,而当松手后translateY
会置为0
这个时候curIdx
会改变,我们就可以计算出真实需要偏移的位置。
# 功能扩展
我期望把他做成一个公共组件,类似于一个CustomSwiper,中间的内容不管是放视频或者是放什么都行,组件本身只关注切换的逻辑与交互动画。这个时候就需要用到插槽了,组件内渲染的东西由外部决定
// 现在父组件中定义一个内容组件
const RenderChilrden = ({ item, isActive, index }) => {
return (
<>
<View className="flex flex-cloumn align-center justify-center">
<View>
插槽接收到的值:{item}
</View>
<View>
当前索引:{index}
</View>
<View>
是否被激活:{isActive ? '是' : '否'}
</View>
</View>
</>
)
}
// 接下来把他传入到我们的CustomSwiper组件中
export default function Index() {
const [swiperList, setSwiperList] = useState([
'#FF6B6B', // 红色
'#4ECDC4', // 青色
'#45B7D1', // 蓝色
'#96CEB4', // 薄荷绿
'#FFEEAD', // 淡黄色
'#D4A5A5', // 粉褐色
'#9B59B6', // 紫色
'#3498DB', // 天蓝色
'#E67E22', // 橙色
'#2ECC71' // 绿色
]);
useLoad(() => {
console.log("Page loaded.");
});
return (
<>
<View>
<VideoSwiper swiperList={swiperList}>
{
({ item, index, isActive }) => {
return <RenderChilrden
item={item}
index={index}
isActive={isActive}
/>
}
}
</VideoSwiper>
</View>
</>
);
}
// 注意这里是以函数的形式传入而不是直接传入一个<RenderChilrden/>,因为我们的内容组件还需要接受CustomSwiper组件的传值
// CustomSwiper组件中这样写
function CustomSwiper ({item,children}){
return(
<>
{visibleItems.map((item) => (
<View
key={item.virtualIndex}
className='swiper-item'
style={{
background: item.data,
transform: `translateY(${(item.virtualIndex) * 100}%)`
}}
>
{
children({
item: item.data,
index: item.virtualIndex,
isActive: item.virtualIndex === curIdx
})
}
</View>
))}
</>
)
}
// 这里我们直接调用children函数然后传入参数,外部那个内容组件就可以接收到传值了
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
上面就完成了组件的功能性扩展,把他抽象成了一个外壳组件,只关注交互不关注渲染内容,这样以后可以使用在多个场景
接下来我们还可以加入一个小小的节流逻辑,防止用户切换的过快导致一些问题
// 这里需要引入两个新变量
const isAnimating = useRef(false); // 节流标志
const timer = useRef<NodeJS.Timeout>();
const handleTouchStart = (e: any) => {
// 如果isAnimating为true则不执行本次逻辑
if (isAnimating.current) return;
touchStartY.current = e.touches[0].clientY;
};
const handleTouchMove = (e: any) => {
// 如果isAnimating为true则不执行本次逻辑
if (isAnimating.current) return;
e.preventDefault?.(); // 阻止默认滚动
e.stopPropagation?.();
touchMoveY.current = e.touches[0].clientY;
const distance = touchMoveY.current - touchStartY.current;
if ((curIdx === 0 && distance > 0) || (curIdx === swiperList.length - 1 && distance < 0)) {
setTranslateY(distance * 0.3);
return;
}
setTranslateY(distance);
};
const handleTouchEnd = () => {
// 如果isAnimating为true则不执行本次逻辑
if (isAnimating.current) return;
const distance = touchMoveY.current - touchStartY.current;
if (Math.abs(distance) > 100) {
isAnimating.current = true; // 开始切换时设置动画状态
if (distance > 0 && curIdx > 0) {
setCurIdx(curIdx - 1);
} else if (distance < 0 && curIdx < swiperList.length - 1) {
setCurIdx(curIdx + 1);
}
// 等待动画完成后重置状态
if (timer.current) {
clearTimeout(timer.current);
}
timer.current = setTimeout(() => {
isAnimating.current = false;
}, 500); // 短暂延迟后放开限制,允许切换
}
setTranslateY(0);
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
以下是完整代码
import { View } from "@tarojs/components"
import { useRef, useState, useMemo } from "react";
import "./index.scss";
export default function CustomSwiper({ swiperList, children }) {
const [curIdx, setCurIdx] = useState(0);
const [translateY, setTranslateY] = useState(0);
const touchStartY = useRef(0);
const touchMoveY = useRef(0);
const isAnimating = useRef(false); // 节流标志
const timer = useRef<NodeJS.Timeout>();
// 只渲染当前项及其前后各一项
const visibleItems = useMemo(() => {
const start = Math.max(0, curIdx - 1);
const end = Math.min(swiperList.length, curIdx + 2);
return swiperList.slice(start, end).map((item, index) => ({
data: item,
virtualIndex: start + index
}));
}, [swiperList, curIdx]);
// 计算当前项的位置
const basePosition = useMemo(() => {
return -curIdx * 100;
}, [curIdx]);
const handleTouchStart = (e: any) => {
if (isAnimating.current) return;
touchStartY.current = e.touches[0].clientY;
};
const handleTouchMove = (e: any) => {
if (isAnimating.current) return;
e.preventDefault?.(); // 阻止默认滚动
e.stopPropagation?.();
touchMoveY.current = e.touches[0].clientY;
const distance = touchMoveY.current - touchStartY.current;
if ((curIdx === 0 && distance > 0) || (curIdx === swiperList.length - 1 && distance < 0)) {
setTranslateY(distance * 0.3);
return;
}
setTranslateY(distance);
};
const handleTouchEnd = () => {
if (isAnimating.current) return;
const distance = touchMoveY.current - touchStartY.current;
if (Math.abs(distance) > 100) {
isAnimating.current = true; // 开始切换时设置动画状态
if (distance > 0 && curIdx > 0) {
setCurIdx(curIdx - 1);
} else if (distance < 0 && curIdx < swiperList.length - 1) {
setCurIdx(curIdx + 1);
}
// 等待动画完成后重置状态
if (timer.current) {
clearTimeout(timer.current);
}
timer.current = setTimeout(() => {
isAnimating.current = false;
}, 500); // 与过渡动画时间保持一致
}
setTranslateY(0);
};
// // 自动轮播
// const handleAutoPlay = () => {
// if (curIdx < swiperList.length - 1) {
// setCurIdx(curIdx + 1);
// } else {
// setCurIdx(0);
// }
// };
// useEffect(() => {
// const interval = setInterval(handleAutoPlay, 3000);
// return () => clearInterval(interval);
// }, [curIdx]);
return (
<View className="custom-swiper">
<View
className="swiper-content"
style={{
height: `${swiperList.length * 100}vh`,
transform: `translateY(calc(${basePosition}vh + ${translateY}px))`,
transition: translateY === 0 ? 'transform 0.3s' : 'none'
}}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{visibleItems.map((item) => (
<View
key={item.virtualIndex}
className='swiper-item'
style={{
background: item.data,
transform: `translateY(${(item.virtualIndex) * 100}%)`
}}
>
{
children({
item: item.data,
index: item.virtualIndex,
isActive: item.virtualIndex === curIdx
})
}
</View>
))}
</View>
</View>
)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
.custom-swiper {
width: 100vw;
height: 100vh;
overflow: hidden;
position: fixed; // 添加 fixed 定位
top: 0;
left: 0;
.swiper-content {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
}
.swiper-item {
width: 100%;
height: 100vh;
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.3s;
will-change: transform;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# 总结
整体的实现就是先实现一个虚拟列表,然后通过手指滑动行为去替代滚动效果已实现我们需要的切换效果,然后再添加一些优化交互的逻辑就可以啦。