前端学习踩坑(5)——更高级的手势需求

引言

经过上次与那个小滑块的斗智斗勇后,我对于AnimatedPanResponder库的理解已经较为深刻了,所以在开发这次需求时,其实并没有花费太多时间和精力。并且现在这个需求还处于一个未完成的状态,目前只简单的整理一下手势检测的部分。

关于上次的小滑块和AnimatedPanResponder的更多内容(更基础)可以参见下面链接:

前端学习踩坑(3) | LittleBlack

需求

image-20220827120842096

​ 其实这次并不算是设计给我出的难题,这应该算是一个最基本的照片裁剪功能。因为一些项目结构的特殊性,使得我不得不尝试自己从0开始写一个相关的功能。其中需求主要有以下几大难题:

  1. 中间透明外部渐变的框如何实现
  2. 图片显示和手势判断不在同一层(图片在底层,但是中间夹了框、按钮、导航栏等)
  3. 手势的实现(单指移动,双指缩放)
  4. 图片的裁剪(未实现)

实现

中间透明外部渐变的框如何实现

直接在scss中使用原生css中的渐变代码是没办法实现的,如果一定要用的话,可以使用RN的渐变库react-native-linear-gradient

react-native-linear-gradient/react-native-linear-gradient: A component for react-native (github.com)

考虑到学习成本和工期,我选择了直接将设计的图片导出,覆盖在上面。视图层逻辑如下:

按照z轴排序(从高到低)

  • 导航栏 / 下方按钮 / 上方提示文字
  • 手势检测层(使用绝对定位)
  • 渐变展示框
  • 图片(使用绝对定位)

图片显示和手势判断不在同一层

有了上文的视图层z轴排序,我们就可以开始着手编写代码了。View的编写和布局问题不再赘述。

代码如下:

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
return (
<View style={Styles.photoPickerDiv}>
<WCNavigation navigation={navigation} backName={"UserValuesInput"} header={"编辑头像"} style={{zIndex: 20}}/>
<View style={{position: 'absolute', zIndex: 10, width: windowWidth}}>
<Image source={require("../../assets/images/login/Subtract.png")}
style={{height: windowHeight, width: windowWidth, resizeMode: 'cover'}}/>
</View>
<View style={Styles.photoPickerBlock}>
<Text style={{marginTop: 40, zIndex: 20}}>
移动和缩放
</Text>
<TouchableOpacity onPress={() => {
}}>
<Image source={require("../../assets/images/login/photoPickerOk.png")} style={{width: 100, height: 100}}/>
</TouchableOpacity>
</View>
<View style={{flex: 1, justifyContent: 'center', alignItems:'center', width: '100%', height: "100%", position: 'absolute'}}>
<Animated.View style={{zIndex: 50, position: 'absolute', width: "100%",
height: "100%", transform: [{translateY: 100}]}}
{...panResponder.panHandlers}/>
<Animated.View style={{transform: [{translateX: pan.x}, {translateY: pan.y}, {scale: currentScale}], zIndex: 0, position: 'absolute'}}>
<Image source={{uri: photoUrl}} style={{width: "100%", height: "100%"}}/>
</Animated.View>
</View>

</View>
);

其中解决这个问题的核心代码片段如下:

1
2
3
4
5
6
7
8
<View style={{flex: 1, justifyContent: 'center', alignItems:'center', width: '100%', height: "100%", position: 'absolute'}}>
<Animated.View style={{zIndex: 50, position: 'absolute', width: "100%",
height: "100%", transform: [{translateY: 100}]}}
{...panResponder.panHandlers}/>
<Animated.View style={{transform: [{translateX: pan.x}, {translateY: pan.y}, {scale: currentScale}], zIndex: 0, position: 'absolute'}}>
<Image source={{uri: photoUrl}} style={{width: "100%", height: "100%"}}/>
</Animated.View>
</View>

通过设置zIndex,将用于接受panResponder的View层防止在较高的位置,但是它只负责接受,数据只会作用在下方的图片层。考虑到功能,主要是x和y方向的translatesccale

其余组件直接设置zIndex调整即可。

手势的实现

这里贴出最核心的实现基础:

原生事件是指由以下字段组成的合成触摸事件:

  • nativeEvent
    • changedTouches - 在上一次事件之后,所有发生变化的触摸事件的数组集合(即上一次事件后,所有移动过的触摸点)
    • identifier - 触摸点的 ID
    • locationX - 触摸点相对于父元素的横坐标
    • locationY - 触摸点相对于父元素的纵坐标
    • pageX - 触摸点相对于根元素的横坐标
    • pageY - 触摸点相对于根元素的纵坐标
    • target - 触摸点所在的元素 ID
    • timestamp - 触摸事件的时间戳,可用于移动速度的计算
    • touches - 当前屏幕上的所有触摸点的集合

一个gestureState对象有如下的字段:

  • stateID - 触摸状态的 ID。在屏幕上有至少一个触摸点的情况下,这个 ID 会一直有效。
  • moveX - 最近一次移动时的屏幕横坐标
  • moveY - 最近一次移动时的屏幕纵坐标
  • x0 - 当响应器产生时的屏幕坐标
  • y0 - 当响应器产生时的屏幕坐标
  • dx - 从触摸操作开始时的累计横向路程
  • dy - 从触摸操作开始时的累计纵向路程
  • vx - 当前的横向移动速度
  • vy - 当前的纵向移动速度
  • numberActiveTouches - 当前在屏幕上的有效触摸点的数量

摘自官方文档:PanResponder · React Native 中文网

其中我们要用到的主要有:

​ 用于计算偏移:

  • gestureState.dx横向偏移量

  • gestureState.dy纵向偏移量

​ 用于计算缩放:

  • evt.nativeEvent当前触摸事件
  • evt.nativeEvent.changedTouches[1]改变的触摸事件数组,1表示另一个触摸事件,0是自己。

偏移很简单,详见前端学习踩坑(3) | LittleBlack中对于官方案例的讲解,此处基本一致。

缩放就稍微费点功夫,我们要如何通过计算出缩放呢?

QQ图片20220827123905

这就好比,两个指头在最外侧然后一起往内侧滑动,我们通过手指间当前的距离除以原来的距离,就可以算出缩放的量。

但是要考虑到这只是一个增量,并不是图片真实的scale,所以我们应该在原先scale的基础上乘以我们算出的这个增量。

核心代码逻辑如下:

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
let startLen = useRef(0).current;

let newLen = useRef(0).current;

let currentScale = useRef(new Animated.Value(1)).current;

const panResponder = useRef(
PanResponder.create({
onMoveShouldSetPanResponder: () => true,
onPanResponderGrant: () => {
pan.setOffset({
// @ts-ignore
x: pan.x._value,
// @ts-ignore
y: pan.y._value,
});
},
onPanResponderMove: (evt, gestureState) => {
// 使用临时变量保存原先scale的值
// @ts-ignore
let temp = currentScale._value;
// 当检测到触控点数为1时,进行偏移探测
if (gestureState.numberActiveTouches === 1) {
pan.x.setValue(gestureState.dx);
pan.y.setValue(gestureState.dy);
} // 当触控点数为2时,计算缩放
else if (gestureState.numberActiveTouches === 2) {
// 触控事件2
const point1 = evt.nativeEvent.changedTouches[1];
// 触控事件1
const point2 = evt.nativeEvent;
// 计算出新的长度
newLen = Math.sqrt((point1.locationX - point2.locationX) ** 2 + (point1.locationY - point2.locationY) ** 2);
// 这个判定主要用于处理边界值,因为初始化的startLen为0,直接进行运算会出现问题,所以第一次触摸检测要跳过计 算,只用于给startLen赋值
if (startLen !== 0 && newLen !== 0) {
// 更改当前scale
currentScale.setValue(temp * newLen / startLen);
console.log(currentScale);
}
// 保存这次计算的新长度用于下次计算(它就是下次的旧长度)
startLen = newLen;
}
},
onPanResponderRelease: () => {
pan.flattenOffset();
// 缩放结束后应该把旧长度置为0,否则下次缩放的第一次运算会使用原来的脏数据,造成与我们预期不一致的错误
startLen = 0;
},
})
).current;

目前这段代码能够较为正常的实现相关的动能,但是还是有些小小的问题。

由于人的手指抬起时不可能做到永远同时抬起,这就会导致缩放结束后,我们抬手时,会检测到触控点数为1的情况并进行偏移量,但是实际上我们只是在进行缩放,并不需要偏移,所以会有肉眼上可见的图片的小跳动。