前端学习踩坑(3)——一个小滑块的开发过程

需求

在React-Native Expo项目中,需要一个可以拖动的滑块。但是:

  • 滑块的可拖动范围需要被约束
  • 滑块的拖动按钮为图片,且图片需要随着滑块所在点位的不同发生变化
  • 滑块的拖动过程中,有一定数量的吸附点位,当松手后滑块应该自动吸附到最近的点位上

初探(4hours)

看起来这么一个好看炫酷的组件,当我看到它的第一眼自然也是很喜欢的。

但是!!问题就在于开发是由我来负责的,这么一个小小的功能所要实现要涉及到的知识可不少啊。

早在设计评审之前,我就已经和设计的同学沟通了关于这个滑块的相关事宜。

image-20220815005258968

看在他这么关心我的份上,开学当面感谢他的时候就少砍两刀吧

首先,我秉持着白嫖优先的原则,搜索了相关的RN开源组件。我发现了这么一个组件:

image-20220815005512139

callstack/react-native-slider: React Native component exposing Slider from iOS and SeekBar from Android (github.com)

我兴奋的安装并上手测试了一下它,发现只需要一行import和一行<Slider>就可以创建出一个优雅的滑动条,我十分开心,一度觉得稳了。但是就在我查阅它的README时,我发现事情似乎没那么简单。

react-native-slider虽然支持对于滑动按钮图片的设置,但是关于如何更改图片的样式,文档中却只字未提。

我原以为是我理解的问题,似乎只是一个Image.propsType的理解问题,我又查阅了RN官方关于Image的文档,尝试在Image的source对象中添加对于图片大小的约束,发现并没有效果

于是,我被迫使用了一种很不优雅的手段,我直接在Figma导出图片时,手动调整了图片的大小,就在我以为虽然很不优雅但是至少能解决问题的时候,我惊讶的发现:图片的大小没有发生变化。

我一脸懵逼啊,只能求助万能的Github,然后我看到了这个:

image-20220815010211183

How to adjust the size of the thumb button · Issue #97 · callstack/react-native-slider (github.com)

原来早在3年前,就有人和我提出了一样的问题,看来至少不是我的水平问题(其实就是)。

image-20220815010323459

image-20220815010905318

看来这个组件的开发者自己都还没有解决这个问题啊!!!!

然后我看到了一个热心网友给出的解决方案:

image-20220815010401691

我像是抓到了救命稻草一般,立刻去了react-native-vector-icons的仓库地址,照着README一顿安装 + 配置,然后我得到了以下问题:

image-20220815010721818

image-20220815010734942

寄!所以这条白嫖之路行不通了啊,谈判失败,开始攻坚。

上手开发(13hours)

在CSDN的帮助下,我了解到了开发这个功能需要的几个库,其中主要包括View, Image, Animated, PanResponder,都是RN的原生库,不用太担心多平台的兼容问题。

核心功能仍有以下三点:

  • 滑块的可拖动范围需要被约束
  • 滑块的拖动按钮为图片,且图片需要随着滑块所在点位的不同发生变化
  • 滑块的拖动过程中,有一定数量的吸附点位,当松手后滑块应该自动吸附到最近的点位上

万幸,在RN官方的PanResponder文档中,就给出了和需求差不多的官方案例,下面只需要理解其中的原理就可以开始面向文档写代码了!

PanResponder · React Native

以下为官方案例:

1
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
import React, { useRef } from "react";
import { Animated, View, StyleSheet, PanResponder, Text } from "react-native";

const App = () => {
const pan = useRef(new Animated.ValueXY()).current;

const panResponder = useRef(
PanResponder.create({
onMoveShouldSetPanResponder: () => true,
onPanResponderGrant: () => {
pan.setOffset({
x: pan.x._value,
y: pan.y._value
});
},
onPanResponderMove: Animated.event(
[
null,
{ dx: pan.x, dy: pan.y }
]
),
onPanResponderRelease: () => {
pan.flattenOffset();
}
})
).current;

return (
<View style={styles.container}>
<Text style={styles.titleText}>Drag this box!</Text>
<Animated.View
style={{
transform: [{ translateX: pan.x }, { translateY: pan.y }]
}}
{...panResponder.panHandlers}
>
<View style={styles.box} />
</Animated.View>
</View>
);
}

const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center"
},
titleText: {
fontSize: 14,
lineHeight: 24,
fontWeight: "bold"
},
box: {
height: 150,
width: 150,
backgroundColor: "blue",
borderRadius: 5
}
});

export default App;

看起来很简单的一个案例,但是对于我这种Animated和PanResponse无任何了解的菜鸡来说,还是花了相当多的时间的(3hours吧)。

1
2
3
const pan = useRef(new Animated.ValueXY()).current;

const panResponder = useRef(

通过useRef Hook来新建一个用于存储二维动画value的变量pan。使用Ref而不使用state主要是为了设置state造成的re-render和闭包造成的变量无法正常修改。

然后我们来看creat方法中的三个属性:

1
2
3
4
5
6
onPanResponderGrant: () => {
pan.setOffset({
x: pan.x._value,
y: pan.y._value
});
},

这里我还是很好奇,虽然Animated.Value自带一个_value属性,但是ts检查总是报错,现在也还没弄懂为啥。

从这里开始引出对于Animated.Value的理解问题。Animated.Value中有两个很重要的属性,一个是offset,一个是value。而上面的代码正是对offset的设置。

关于这部分,我也和mentor讨论了很久,最后我借助坐标系和向量的概念来理解了这两个属性。

对于value的真实值,我们可以理解为在offset基础上 + pan的偏移量计算出的位置。

value的值对应的坐标系就是一个静止的恒定的基础坐标系。而offset的值则是上一次滑动后计算出来的value位置的坐标系。

因为只有在上一次偏移所在的坐标系上来和这次的手势做计算,计算出相对于初始坐标系的值才是正确的,否则就相当于每次滑动都是把滑块从初始坐标系的原点开始滑动。

1
2
3
4
5
6
onPanResponderMove: Animated.event(
[
null,
{ dx: pan.x, dy: pan.y }
]
),

中间的这段代码看起来使用的一个Animated.event方法,但是其实很好理解,可以理解为一种对于pan的解构赋值。这段代码等价于:

1
2
3
4
onPanResponderMove: (evt, gestureState) => {
pan.x.setValue(gestureState.dx);
pan.y.setValue(gestureState.dy);
},

而其中的gestureState.dx,gestureState.dy则表示在不同方向上的手势的偏移量。

最后,使用这段代码,使用偏移量和手势偏移量计算出相对于原始坐标系的值,保存在pan中,并清空偏移量,在开头将上次计算出的结果置为新的偏移量。

1
2
3
onPanResponderRelease: () => {
pan.flattenOffset();
}

以上便是这个例子中最核心的逻辑,而我们也不难想到,如果要进行可滑动范围的约束,我们应该从onPanResponderMove方法下手,而控制最终的吸附,我们应该从onPanResponderRelease方法下手。

想法可行,实践开始!

在实际开发过程中,由于我的需求只需要一个方向的值,所以我直接使用了而不是Animated.ValueXY。

但是Animated.Value的计算操作很复杂,需要调用它给出的方法,并且没办法直接使用关系运算符比较大小,这对于范围的约束功能开发来说是很难接受的一点。

所以,我想到使用一个number类型的变量来保存pan的值,由它代替pan来进行运算和判断操作,并将计算好的值赋值给pan,将该变量记为point。

point会在每次release时加等于新的偏移量,确实和上文的pan的原理相同,但是由于数字的方便性,操作简单的多。

1
point += gestureState.dx;

如何在移动过程中判断手指是否超过了界限约束呢?在手指移动中,我们能获得到的相关数据都可以从gestureState获取。point为我们保存了上一次滑动结束的坐标,同样,这也是这次滑动开始的初始坐标,我们新建一个temp,让它继承point的值并加上随着滑动不断变化的gestureState.x。一旦temp大于了约束界限,不就说明滑块划出了既定的界限么?

1
2
3
4
5
6
7
8
9
10
11
12
13
onPanResponderMove: (evt, gestureState) => {
const temp = point + gestureState.dx;
// console.log(temp);
// console.log(pan);
if (temp > WIDTH / 2) {
// console.log('point' + point);
pan.setValue(WIDTH / 2);
} else if (temp < -WIDTH / 2) {
pan.setValue(-WIDTH / 2);
} else {
pan.setValue(gestureState.dx);
}
},

这下就简单了,我们只需要在判断到temp超过界限时,手动设置pan的值让滑块停止滑动就好了嘛~

但是事实并不是这样子,这段代码中有一个很重要的问题,当时的我花费了相当多的时间和精力才找出问题。

image-20220816203235378

这个offset一旦设置,你随后使用setValue方法时,也会在offset的基础上进行计算,也就是说你不能直接更改最后计算出的相对于原始坐标的值。所以,我们应该在setValue的基础上,减去offset来抵消setOffset对我们想设置原始值的影响,最后代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
onPanResponderMove: (evt, gestureState) => {
const temp = point + gestureState.dx;
// console.log(temp);
// console.log(pan);
if (temp > WIDTH / 2) {
// console.log('point' + point);
pan.setValue(WIDTH / 2 - point);
} else if (temp < -WIDTH / 2) {
pan.setValue(-WIDTH / 2 - point);
} else {
pan.setValue(gestureState.dx);
}
},

到此为止,约束滑块滑动范围的任务就完成了。

让我们进入onPanResponderRelease来看看如何实现松手后滑块的吸附功能。

首先,为了实现上文提到的用point来保存pan的值,我们需要在point的基础上加等于gestureState.dx。但是还不仅仅是这样,由于move中的约束,并不是所有的dx都可以被加上,最终的结果只能在约束范围之内,所以完整代码如下:

1
2
3
4
5
6
7
8
9
10
onPanResponderRelease: (evt, gestureState) => {
point += gestureState.dx;
if (point > WIDTH / 2) {
point = WIDTH / 2;
}
if (point < -WIDTH / 2) {
point = -WIDTH / 2;
}
pan.flattenOffset();
},

然后,通过一个简单的数学计算,算出什么位置该吸附在哪个点位,并使用Animated中的spring弹性动画来控制吸附,让它变得更加丝滑,完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
onPanResponderRelease: (evt, gestureState) => {
point += gestureState.dx;
if (point > WIDTH / 2) {
point = WIDTH / 2;
}
if (point < -WIDTH / 2) {
point = -WIDTH / 2;
}

pointNum = Math.trunc(((point + WIDTH / 2) / group + 1) / 2);
// const prev = point;
console.log(pointNum);
point = pointNum * 2 * group - WIDTH / 2;
// console.log(point);
// // if (point !== pan._value) {
Animated.spring(pan, {toValue: point, useNativeDriver: true}).start();
// }
// console.log(pan);

// pan.setValue(point);
pan.flattenOffset();
}

如果说能够很好的解决Animated.Value中的_value报错问题,似乎可以直接使用_value来代替point(错误不影响编译和运行,只存在于IDE中。