HTML第55中学手势原理深入分析与数学知识的实践

作者:分分快三计划

singleRotate(单指旋转)

分分快三计划 1

结合单指缩放和双指旋转,可以很简单的知道 θ便是我们需要的旋转角度;

// 获取初始向量与实时向量 let rotateV1 = getVector(startPoint, singleBasePoint); let rotateV2 = getVector(curPoint, singleBasePoint); // 通过 getAngle 获取旋转角度并触发事件; this._eventFire('singleRotate', { delta: { rotate: getAngle(rotateV1, rotateV2), }, origin: ev, });

1
2
3
4
5
6
7
8
9
10
11
// 获取初始向量与实时向量
let rotateV1 = getVector(startPoint, singleBasePoint);
let rotateV2 = getVector(curPoint, singleBasePoint);
 
// 通过 getAngle 获取旋转角度并触发事件;
this._eventFire('singleRotate', {
    delta: {
        rotate: getAngle(rotateV1, rotateV2),
    },
    origin: ev,
});

平面图形几何变换

平移变换是将图形中的每一个点从一个位置移动到另一个位置的变换,tx,ty称为平移距离,则平移变换公式为:

分分快三计划 2分分快三计划 3平移变换

旋转变换是以某个参考点为圆心,将图像上的各点围绕圆心转动一个逆时针角度θ,变为新的坐标的变换。当参考点为时,旋转变换的公式为:

分分快三计划 4

由于:

分分快三计划 5

所以可化简为:

分分快三计划 6分分快三计划 7旋转变换

比例变换是使对象按比例因子放大或缩小的变换

分分快三计划 8分分快三计划 9比例变换

Rotate(双指旋转)

分分快三计划 10

初始时双指向量a,旋转到b向量,θ便是我们需要的值,因此只要通过我们上面构建的getAngle函数,便可求出旋转的角度:

// a向量; let vector1 = getVector(secondPoint, startPoint); // b向量; let vector2 = getVector(curSecPoint, curPoint); // 触发事件; this._eventFire('rotate', { delta: { rotate: getAngle(vector1, vector2), }, origin: ev, });

1
2
3
4
5
6
7
8
9
10
11
12
13
// a向量;
let vector1 = getVector(secondPoint, startPoint);
 
// b向量;
let vector2 = getVector(curSecPoint, curPoint);
 
// 触发事件;
this._eventFire('rotate', {
    delta: {
        rotate: getAngle(vector1, vector2),
    },
    origin: ev,
});

如果有一种法则T,对平面点集中的每个点A,都对应平面上唯一的一个点T,则T称为平面上的一个变换,T称为A的像。变换是函数概念的自然推广。平面上的图形由点组成,因而平面上的变换T会将一个图形C变到另一个图形T称为C的像。从这个意义上说,可以称T为几何变换。例如对图形作平移变换、旋转变换、缩放变换、对称变换等都是几何变换。在平面直角坐标系中,点A由坐标表示。在变换T下,点A的像为A',其中x'和y'都是x,y的函数:x' = f1, y' = f2因此,函数f1,f2能够确定一个平面上的变换T。如果能够从方程组中反解出x和y:x = g1, y = g2则由函数g1,g2确定了T的逆变换,记为T-1。设平面曲线C的参数方程为:x = x, y = y, t∈D其中D是函数x的定义域,则曲线C在变换T下的像T的参数方程为x = f1), y = f2, y, t∈D

Drag(拖动事件)

分分快三计划 11

上图是模拟了拖动手势,由A点移动到B点,我们要计算的便是这个过程的偏移量;

因此我们在touchstart中记录初始点A的坐标:

// 获取初始点A; let startPoint = getPoint(ev,0);

1
2
// 获取初始点A;
let startPoint = getPoint(ev,0);

然后在touchmove事件中获取当前点并实时的计算出△x△y

// 实时获取初始点B; let curPoint = getPoint(ev,0); // 通过A、B两点,实时的计算出位移增量,触发 drag 事件并传出参数; _eventFire('drag', { delta: { deltaX: curPoint.x - startPoint.x, deltaY: curPoint.y - startPoint.y, }, origin: ev, });

1
2
3
4
5
6
7
8
9
10
11
// 实时获取初始点B;
let curPoint = getPoint(ev,0);
 
// 通过A、B两点,实时的计算出位移增量,触发 drag 事件并传出参数;
_eventFire('drag', {
    delta: {
        deltaX: curPoint.x - startPoint.x,
        deltaY: curPoint.y - startPoint.y,
    },
    origin: ev,
});

Tips: fire函数即遍历执行drag事件对应的回调仓库即可;

UIView的复合变换

UIView *view = [UIView new];view.backgroundColor = [UIColor redColor];view.frame = CGRectMake(200, 200, 100, 100);[self.view addSubview:view];[UIView animateWithDuration:5 animations:^{ // 先平移 CGAffineTransform move = CGAffineTransformMakeTranslation; // 后旋转 CGAffineTransform rotation = CGAffineTransformMakeRotation; view.transform = CGAffineTransformConcat(rotation, move);}];

分分快三计划 12先平移后旋转

先不解释,我们接着再看一个变换

UIView *view = [UIView new];view.backgroundColor = [UIColor redColor];view.frame = CGRectMake(200, 200, 100, 100);[self.view addSubview:view];[UIView animateWithDuration:5 animations:^{ // 先旋转 CGAffineTransform rotation = CGAffineTransformMakeRotation; // 后平移 CGAffineTransform move = CGAffineTransformMakeTranslation; view.transform = CGAffineTransformConcat(move,rotation);}];

分分快三计划 13先旋转后平移

综合上面两个不同顺序的变换,由于View内部坐标系的原点在复合变换的过程中一直跟随View在移动因此平移和旋转的顺序会决定不同的结果。

  • 如果原点在整个变换过程中一直不变,则需要先旋转后平移
  • 如果原点在整个变换过程中一直跟随View,则需要先平移后旋转

目的就是保证旋转始终是围绕原点进行

实现原理

众所周知,所有的手势都是基于浏览器原生事件touchstart, touchmove, touchend, touchcancel进行的上层封装,因此封装的思路是通过一个个相互独立的事件回调仓库handleBus,然后在原生touch事件中符合条件的时机触发并传出计算后的参数值,完成手势的操作。实现原理较为简单清晰,先不急,我们先来理清一些使用到的数学概念并结合代码,将数学运用到实际问题中,数学部分可能会比较枯燥,但希望大家坚持读完,相信会收益良多。

分分快三计划 141-4分分快三计划 151-5

向量模

代表 向量的长度,记为|a|,是一个标量,只有大小,没有方向;

几何意义代表的是以x,y为直角边的直角三角形的斜边,通过勾股定理进行计算;

分分快三计划 16

getLength函数:

分分快三计划 17

  • o = p1a p2b p3c从上面对向量和点的表达,我们可以看出为了在坐标系中表示一个点我们可以把点的位置看作是对于这个基的原点o所进行的一个位移,即一个向量p
  • o,我们在表达这个向量的同时用等价的方式表达出了点p: p = o p1a p2b p3c。,是坐标系下表达一个向量和点的不同表达方式。这里可以看出,虽然都是用代数分量的形式表达向量和点,但表达一个点比一个向量需要额外的信息。如果我写一个代数分量表达,谁知道它是个向量还是一个点。我们现在把,写成矩阵的形式:

singlePinch(单指缩放)

分分快三计划 18

与上面的手势不同,单指缩放和单指旋转都需要多个特有概念:

操作元素(operator):需要操作的元素。上面三个手势其实并不关心操作元素,因为单纯靠手势自身,便能计算得出正确的参数值,而单指缩放和旋转需要依赖于操作元素的基准点(操作元素的中心点)进行计算;

按钮:因为单指的手势与拖动(drag)手势是相互冲突的,需要一种特殊的交互方式来进行区分,这里是通过特定的区域来区分,类似于一个按钮,当在按钮上操作时,是单指缩放或者旋转,而在按钮区域外,则是常规的拖动,实践证明,这是一个用户很容易接受且体验较好的操作方式;

图中由a向量单指放大到b向量,对操作元(正方形)素进行了中心放大,此时缩放值即为b向量的模 / a向量的模;

// 计算单指操作时的基准点,获取operator的中心点; let singleBasePoint = getBasePoint(operator); // touchstart 中计算初始向量模; let pinchV1 = getVector(startPoint,singleBasePoint); singlePinchStartLength = getLength(pinchV1); // touchmove 中计算实时向量模; pinchV2 = getVector(curPoint, singleBasePoint); singlePinchLength = getLength(pinchV2); // 触发事件; this._eventFire('singlePinch', { delta: { scale: singlePinchLength / singlePinchStartLength, }, origin: ev, });

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 计算单指操作时的基准点,获取operator的中心点;
let singleBasePoint = getBasePoint(operator);
 
// touchstart 中计算初始向量模;
let pinchV1 = getVector(startPoint,singleBasePoint);
singlePinchStartLength = getLength(pinchV1);
 
// touchmove 中计算实时向量模;
pinchV2 = getVector(curPoint, singleBasePoint);
singlePinchLength = getLength(pinchV2);
 
// 触发事件;
this._eventFire('singlePinch', {
    delta: {
        scale: singlePinchLength / singlePinchStartLength,
    },
    origin: ev,
});

所谓齐次坐标系就是将一个原本是n维的向量用一个n 1维向量来表示。对于一个向量v以及基oabc,可以找到一组坐标使得v=v1a v2b v3c。而对于一个点p,则可以找到一组坐标使得p

矩阵与变换

由于空间最本质的特征就是其可以容纳运动,因此在线性空间中,

我们用向量来刻画对象,而矩阵便是用来描述对象的运动;

平面图形几何变换的矩阵表示

分分快三计划 19

从变换功能上可以把T2D分为四个子矩阵。其中

分分快三计划 20

是对图形的缩放、旋转、对称、错切等变换;

分分快三计划 21

是对图形进行平移变换;

分分快三计划 22

是对图形作投影变换,g的作用是在x轴的1/g处产生一个灭点,而h的作用是在y轴的1/h处产生一个灭点;i是对整个图形做伸缩变换。平移变换、旋转变换、比例变换、错切变换这4中基本变换都可以表示为3x3的变换矩阵和齐次坐标相乘的形式

平移变换的矩阵表示为

分分快三计划 23

tx,ty分别表示x轴方向和y轴方向的平移距离。

旋转变换的矩阵表示为

分分快三计划 24

逆时针旋转时θ取正值,顺时针旋转时θ取负值

比例变换的矩阵表示为

分分快三计划 25

  • 当b=d=0时,a和e的取值决定了缩放效果,a和e>1放大,<1缩小
  • 当b=d=0,a=-1,e=1时有x'=-x,y'=y产生与y轴对称的图形
  • 当b=d=0,a=1,e=-1时有x'=x,y'=-y产生与x轴对称的图形
  • 当b=d=0,a=e=-1时有x'=-x,y'=-y产生与原点对称的图形
  • 当b=d=1,a=e=0时有x'=y,y'=x产生与直线y=x对称的图形
  • 当b=d=-1,a=e=0时有x'=-y,y'=-x产生与直线y=-x对称的图形

错切变换的矩阵表示为

分分快三计划 26

其中当d = 0时,x' = x by, y' = y,此时,图形的y坐标不变,x坐标随初值及变换系数b作线性变化;当b = 0时,x' = x,y' = dx y,此时,图形的x坐标不变,y坐标随初值及变换系数d作线性变化。

一个比较复杂的变换要连续进行若干个基本变换才能完成。例如围绕任意点的旋转,需要通过3个基本变换T,R,T才能完成。这些由基本变换构成的连续变换序列称为复合变换。变换的矩阵形式使得复合变换的计算工作量大为减少。以绕任意点旋转为例,本应进行如下3次变换,分别是

  • p' = pT 将原点移动到任意点位置
  • p'' = p'R 旋转
  • p = p''T 将原点归位

合并之后为p = pTRT令Tc = TRT则有p = pTc,Tc称为复合变换矩阵。由上面推到可知在计算复合变换时,首先可将各基本变换矩阵按次序想乘,形成总的复合变换矩阵Tc然后,坐标只需与Tc想乘一次,便可同时完成一连串基本变换。因此采用复合变换矩阵可以大大节省坐标乘法所耗费的运算时间。下面我们看几个基本的复合变换:复合平移:对同一图形做两次平移相当于将两次平移相加起来,即

分分快三计划 27

复合缩放:以原点为参考点对同一图形做两次连续的缩放相当于将缩放操作相乘,即:

分分快三计划 28

复合旋转:以原点为参考点对同一图形做两次连续的旋转相当于将两次的旋转角度相加, 即:

分分快三计划 29

缩放、旋转变换都与参考点有关,上面进行的各种缩放、旋转变换都是以原点为参考点的。如果相对某个一般的参考点作缩放、旋转变换,相当于将该点移到坐标原点处,然后进行缩放、旋转变换,最后将点移回原来的位置。如关于的缩放变换为:

分分快三计划 30

各种复杂的变换无非是一些基本变换的组合,利用数学方法也就是矩阵的 乘法来解决复合变换问题,关键是将其分解为一定顺序的基本变换,然后逐一 进行这些基本变换;或者求出这些基本变换矩阵连乘积,即求出复合变换矩阵, 从而使复合变化问题得到解决。

写了这么多只是想把平面仿射变换的基本原理描述清楚,以便能对UIView.transform有更深入的理解。接下来我们进入正题

这里说的坐标系是UIView相对于其父视图的相对位置和大小

分分快三计划 31UIView外部坐标系

如上图以父视图左上角为坐标原点,x轴从原点向右递增,y轴从原点向下递增,通过改变UIView的frame和center可以调整UIView的位置和大小,当然UIView是对CALayer的封装也可以直接调整layer的frame和position达到相同的效果。基于此我们可以调整UIView的位置和大小,或者通过UIView的位置和大小进行适当的动画展示,当然也仅限于此,对于旋转、切变是无能为力的。

  • 设置View的frame和center会改变其位置和大小,同时会改变View的bounds,bounds是View相对于自身的尺寸bounds=(0,0,view.width,view.height)
  • 设置完成frame或者center之后可以通过调整bounds重新设置frame,如果frame = 重新设置bounds = (0,0,w',h')则新的frame=(x',y',w',h')

分分快三计划 32

  • 当然如果在设置完bounds之后再设置frame则bounds会被重置为(0,0,view.width,view.height)

UIView除了刚刚我们说的外部坐标系,还有一个内部坐标系。

分分快三计划 33UIView内部坐标系

跟笛卡尔坐标系稍微有点区别,以UIView视图中心为坐标原点,x轴从原点向右递增,y轴从原点向下递增,通过改变UIView的transform可以对其进行仿射变换,如上面我们提到的缩放、旋转、平移、切变等。有了这个特性UIView能做的事情就更多了,当然也可以借此做更有意思的动画。在内部坐标系中原点的位置可以通过anchorPoint调整,UIView没有开放出来,可以访问CALayer获取。

分分快三计划 34anchorPoint

参考上图通过调整anchorPoint的值可以修改内部坐标系的原点位置,设置可以把原点移动到View的左上角,设置可以把原点移动到右下角,设置可以把原点移动到View中心。当然anchorPoint的值也不限制在[0,1],可以推广到任意浮点值,相应的调整规则类似,比如设置为则可以把原点移动到左上角再向左上偏移一个View的位置。anchorPoint值的修改不只会调整原点位置,同时也会修改View的frame,修改规则如下:

分分快三计划 35

基于View的transform可以进行仿射变换,所有的变化都是基于原点位置进行的,因此anchorPoint的设置可以产生更多有意思的效果,后续我们一个个看

跟anchorPoint的设置一样,transform的设置也会引起frame的调整

分分快三计划 36Transform修改

见上图以旋转变换为例,旋转变换会让原有图形的frame从白色框变为虚线框,我们假设原有View的四个点为p0 p1 p2 p3 则旋转变换之后的点为:p0' = p0Tp1' = p1Tp2' = p2Tp3' = p3T则frame = (x',y',w',h')

分分快三计划 37

我们把上面提到的两个坐标系结合起来看一下

分分快三计划 38内外坐标系

影响View位置和形状的几个参数有:

  • frame
  • center
  • transform
  • bounds
  • anchorPoint

遵循如下规则:

  • 在设置transform之前可以通过frame和center调整View的大小和尺寸,frame的改变会影响bounds,设置bounds会重新修改frame和center,规则参考之前
  • View的transform参考内部坐标系,transform的改变会影响frame和center,但是不会修改bounds
  • 在设置了transform修改之后仍然可以通过调整bounds来修改frame和center也可以直接修改center,transform会根据新的bounds和center来计算新的frame,参考之前
  • anchorPoint的修改会影响transform的原点位置从而产生不同的变换效果,也会引起frame的重新计算

上面的理论知识已经写了很多了,接下来我们实际体验一下,看一下View的transform结构

struct CGAffineTransform { CGFloat a, b, c, d; CGFloat tx, ty;};

结合上面关于线性代数相关的知识,可以发现View的transform最终都转换成了矩阵运算

共线定理

共线,即两个向量处于 平行 的状态,当a=(x1,y1),b=(x2,y2),则存在唯一的一个实数λ,使得a=λb,代入坐标点后,可以得到 x1·y2= y1·x2;

因此当x1·y2-x2·y1>0 时,既斜率 ka > kb ,所以此时b向量相对于a向量是属于顺时针旋转,反之,则为逆时针;

综合应用

借用一个案例来对transform做一个综合的应用,这个案例也是从实际项目中产生的。先看最终效果:

分分快三计划 39综合应用

最近在用一些零散的时间重构之前上架的一款画板应用,希望为画布增加更加灵活的操作方式,在双指拖拽画布的同时可以实现定点的缩放和旋转,可以通过双指点击完成笔迹的撤销,通过三指点击完成笔迹的重做。

把问题拆解一下,为了达到上面展示的效果,需要解决以下问题:

  • 手势的控制,双指拖拽,双指捏合,双指旋转
  • 处理各手势之间的冲突和配合
  • 处理View的平移、旋转、缩放复合变换
  • 其中旋转和缩放变换要以双指连线的中点为旋转或缩放中心

综合分析以上问题首先需要为画布增加一个容器,然后才能在容器上添加手势,通过手势控制画布的frame和transform

/// 画布var canvasView: UIView? = nil { didSet { if self.canvasView != nil { self.addSubview(self.canvasView!); self.canvasView?.backgroundColor = UIColor.white; // 移动到容器中心 self.canvasView!.center = CGPoint(x: self.bounds.size.width/2, y: self.bounds.size.height/2); // transform归零,设置为单位矩阵 self.canvasView!.transform = CGAffineTransform.identity; } }}

添加需要的手势

// 双指点击let doubleTouchesGesture = UITapGestureRecognizer(target: self, action: #selector(gestureRecognizer));doubleTouchesGesture.numberOfTapsRequired = 1;doubleTouchesGesture.numberOfTouchesRequired = 2;doubleTouchesGesture.delegate = self;self.addGestureRecognizer(doubleTouchesGesture);// 三指点击let tripleTouchesGesture = UITapGestureRecognizer(target: self, action: #selector(gestureRecognizer));tripleTouchesGesture.numberOfTapsRequired = 1;tripleTouchesGesture.numberOfTouchesRequired = 3;tripleTouchesGesture.delegate = self;self.addGestureRecognizer(tripleTouchesGesture);// 缩放let pinchGesture = UIPinchGestureRecognizer(target: self, action: #selector(gestureRecognizer));pinchGesture.delegate = self;self.addGestureRecognizer(pinchGesture);// 移动let panGesture = UIPanGestureRecognizer(target: self, action: #selector(gestureRecognizer));panGesture.minimumNumberOfTouches = 2;panGesture.delegate = self;self.addGestureRecognizer(panGesture);// 旋转let rotationGesture = UIRotationGestureRecognizer(target: self, action: #selector(gestureRecognizer));rotationGesture.delegate = self;self.addGestureRecognizer(rotationGesture)

我们需要旋转、移动和缩放同时触发并且在触发旋转、移动或者缩放的时候双指点击不能被触发,但是如果用户使用三指点击时,三指手势要优先触发。因此需要对手势的delegate做一点处理

// MARK: - UIGestureRecognizerDelegateextension CanvasContentView: UIGestureRecognizerDelegate { func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { // 各手势之间要并发进行 return true; } func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool { if (gestureRecognizer is UIPanGestureRecognizer || gestureRecognizer is UIRotationGestureRecognizer || gestureRecognizer is UIPinchGestureRecognizer) && otherGestureRecognizer is UITapGestureRecognizer { // 移动、旋转、缩放时要避免双指点击触发 if otherGestureRecognizer.numberOfTouches == 3 { // 三指点击时用户意图明显,因此要优先触发 return false; } return true; } return false; }}

这样各种手势就可以相互配达到我们的需求

分分快三计划 40绕固定点旋转

如上图,如果是画布绕其中心旋转是很容易实现的,不需要调整View原点位置直接旋转θ角度即可。如果旋转点不在画布中心处理起来就要麻烦一点。有两种方案可以实现

  • 1、调整anchorPoint把View坐标原点移动到旋转点位置,然后通过transform设置让View旋转θ
  • 2、拆解绕点旋转变换为:先把View中心移动到目标位置,然后旋转θ角度

分析一下看一下哪种方案更合适,如果调整anchorPoint必然会引起frame的改变,也就是center位置的变化,需要在anchorPoint调整之后恢复center的位置,另外如果View在初始状态是比较容易通过旋转中心点的坐标推算出anchorPoint的新位置,但是一旦View发生了旋转就很难再计算出新的anchorPoint的位置。而方案2只需要计算出旋转过程中View中心点的位置变化即可。根据之前的理论知识坐标系中的一个点绕另一个点的旋转变换可以表示为:

分分快三计划 41

化简之后为:

分分快三计划 42

看一下部分代码实现:

private func rotateAt(center: CGPoint, rotation: CGFloat) { self.gestureParams.rotation = self.gestureParams.rotation   rotation; // x = cosθ - sinθ   x0 // y = cosθ   sinθ   y0 let x1 = self.canvasView!.center.x; let y1 = self.canvasView!.center.y; let x0 = center.x; let y0 = self.bounds.size.height - center.y; let x =  * cos -  * sin   x0 let y =  * cos    * sin   y0; self.canvasView!.center = CGPoint(x: x, y: y); self.canvasView!.transform = CGAffineTransform.identity.rotated(by: self.gestureParams.rotation).scaledBy(x: self.gestureParams.scale, y: self.gestureParams.scale);}

分分快三计划 43以固定点为中心缩放

跟旋转类似以固定点为中心的缩放依然可以选择两种方案,我们依然以选择第二中方案,先把中心点移动到目标位置然后进行缩放变换矩阵表示为:

分分快三计划 44

化简为:

分分快三计划 45

看一下部分代码

private func scaleAt(center: CGPoint, scale: CGFloat) { // x' = Sx   x0 // y' = Sy   y0 let formerScale = self.gestureParams.scale; self.gestureParams.scale = scale * self.gestureParams.scale; self.gestureParams.scale = min(max(self.minScale, self.gestureParams.scale), self.maxScale); let currentScale = self.gestureParams.scale/formerScale; let x = self.canvasView!.center.x; let y = self.canvasView!.center.y; let x1 = currentScale * (x - center.x)   center.x; let y1 = currentScale * (y - center.y)   center.y; self.canvasView!.center = CGPoint(x: x1, y: y1); self.canvasView!.transform = CGAffineTransform.identity.rotated(by: self.gestureParams.rotation).scaledBy(x: self.gestureParams.scale, y: self.gestureParams.scale);}

最主要的问题其实都已经解决掉了,接下来就是把手势信息转换为我们需要的数据即可,这里不做过多的解释了,直接贴代码:

// MARK: - Gesturesextension CanvasContentView { @objc func gestureRecognizer(gesture: UIGestureRecognizer) { if self.canvasView != nil { switch gesture { case is UIPinchGestureRecognizer: let pinchGesture = gesture as! UIPinchGestureRecognizer; if pinchGesture.state == .began || pinchGesture.state == .changed { // 计算缩放的中心点和缩放比例,每次缩放的比例需要累计 var center = pinchGesture.location; if pinchGesture.numberOfTouches == 2 { let center0 = pinchGesture.location(ofTouch: 0, in: self); let center1 = pinchGesture.location(ofTouch: 1, in: self); center = CGPoint(x: (center0.x   center1.x)/2, y: (center0.y   center1.y)/2); } self.scaleAt(center: center, scale: pinchGesture.scale); pinchGesture.scale = 1; self.delegate?.canvasContentView(self, scale: self.gestureParams.scale); } break; case is UIPanGestureRecognizer: let panGesture = gesture as! UIPanGestureRecognizer; let location = panGesture.location; if panGesture.state == .began { // 记录开始位置 self.gestureParams.from = location; self.gestureParams.lastTouchs = gesture.numberOfTouches; }else if panGesture.state == .changed { if self.gestureParams.lastTouchs != panGesture.numberOfTouches { self.gestureParams.from = location; } // 计算偏移量 self.gestureParams.lastTouchs = panGesture.numberOfTouches; let x = location.x - self.gestureParams.from.x; let y = location.y - self.gestureParams.from.y; self.gestureParams.from = location; self.translate(x: x, y: y); self.delegate?.canvasContentView(self, x: x, y: y); } break; case is UIRotationGestureRecognizer: let rotatioGesture = gesture as! UIRotationGestureRecognizer; if rotatioGesture.state == .began || rotatioGesture.state == .changed { // 计算旋转的中心点和旋转角度,每次旋转的角度需要累计 var center = rotatioGesture.location; if rotatioGesture.numberOfTouches == 2 { let center0 = rotatioGesture.location(ofTouch: 0, in: self); let center1 = rotatioGesture.location(ofTouch: 1, in: self); center = CGPoint(x: (center0.x   center1.x)/2, y: (center0.y   center1.y)/2); } self.rotateAt(center: center, rotation: rotatioGesture.rotation); rotatioGesture.rotation = 0; self.delegate?.canvasContentView(self, rotation: self.gestureParams.rotation); } break; case is UITapGestureRecognizer: let tapGesture = gesture as! UITapGestureRecognizer; if tapGesture.numberOfTouches == 2 { self.delegate?.canvasContentView(self, tapTouches: 2); }else if tapGesture.numberOfTouches == 3 { self.delegate?.canvasContentView(self, tapTouches: 3); } break; default: break; } } }}

写了很多,总结一句,UIView在二维状态下的形变多数情况都可以转换为仿射变换或者多个仿射变换的复合变换,从而用矩阵运算的知识解决。以后再遇到比较有意思的问题我会继续补充……

结语

至此,相信大家对手势的原理已经有基础的了解,基于这些原理,我们可以再封装出更多的手势,例如双击,长按,扫动,甚至更酷炫的三指、四指操作等,让应用拥有更多人性化的特质。

基于以上原理,我封装了几个常见的工具:(求star -.-)

Tips: 因为只针对移动端,需在移动设备中打开demo,或者pc端开启mobile调试模式!

  1. mtouch.js : 移动端的手势库,封装了上述的五种手势,精简的api设计,涵盖了常见的手势交互,基于此也可以很方便的进行扩展。
    demo
    github
  2. touchkit.js : 基于mtouch所封装的一层更贴近业务的工具包,可用于制作多种手势操作业务,一键开启,一站式服务。
    demo
    github
  3. mcanvas.js : 基于canvas 开放极简的api实现图片 一键导出等。
    demo
    github

AnchorPoint

如果不修改AnchorPoint则所有的变化都是基于View的中心进行,但是可以通过修改anchorPoint改变原点的位置从而改变变换的效果

UIView *view = [UIView new];view.backgroundColor = [UIColor redColor];view.frame = CGRectMake(200, 200, 100, 100);[self.view addSubview:view];view.layer.anchorPoint = CGPointMake;[UIView animateWithDuration:5 animations:^{ view.transform = CGAffineTransformMakeRotation;}];

分分快三计划 46绕点旋转

如上图可以实现绕点旋转的效果

向量的数量积

向量同样也具有可以运算的属性,它可以进行加、减、乘、数量积和向量积等运算,接下来就介绍下我们使用到的数量积这个概念,也称为点积,被定义为公式:

当a=(x1,y1),b=(x2,y2),则a·b=|a|·|b|·cosθ=x1·x2 y1·y2;

最近在重构之前上架的一款画板应用,期间用到了一些UIView的transform相关的特性。借此机会也系统整理了一下transform相关的知识。在进入正题之前需要补充一点线性代数(数学专业应该叫高等代数)相关的知识。

向量(Vector)

是坐标系中一种 既有大小也有方向的线段,例如由原点O(0,0)指向点A(1,1)的箭头线段,称为向量a,则a=(1-0,1-0)=(1,1);

如下图所示,其中ij向量称为该坐标系的单位向量,也称为基向量,我们常见的坐标系单位为1,即i=(1,0);j=(0,1)

分分快三计划 47

获取向量的函数:分分快三计划 48

 

这里是坐标基矩阵,左边的行向量分别是向量v和点p在基下的坐标。这样,向量和点再同一个基下就有了不同的表达:三维向量的第四个代数分量是0,而三维点的第四个代数分量是1。像这种用四个代数分量表示三维几何概念的方式是一种齐次坐标表示。这样,上面的如果写成,它就是个向量;如果是它就是个点。由于齐次坐标使用了4个分量来表达3D概念或者说用了3个分量来表达2D概念,从而使得放射变换可以使用矩阵进行。

引言

在这触控屏的时代,人性化的手势操作已经深入了我们生活的每个部分。现代应用越来越重视与用户的交互及体验,手势是最直接且最为有效的交互方式,一个好的手势交互,能降低用户的使用成本和流程,大大提高了用户的体验。

近期,公司的多个项目中都对手势有着较高的需求,已有的手势库无法完全cover,因此便撸了一个轻量、便于使用的移动端手势库。这篇博文主要是解析了移动端常用手势的原理,及从前端的角度学习过程中所使用的数学知识。希望能对大家有一点点的启发作用,也期待大神们指出不足甚至错误,感恩。

主要讲解项目中经常使用到的五种手势:

  • 拖动: drag
  • 双指缩放: pinch
  • 双指旋转: rotate
  • 单指缩放: singlePinch
  • 单指旋转: singleRotate

Tips :
因为 tapswipe 很多基础库中包含,为了轻便,因此并没有包含,但如果需要,可进行扩展;

点(Point)

可以理解为我们的坐标点,例如原点O(0,0),A(-1,2),通过原生事件对象的touches可以获取触摸点的坐标,参数index代表第几接触点;分分快三计划 49

 

而矩阵是如何描述运动的呢?

我们知道,通过一个坐标系基向量便可以确定一个向量,例如 a=(-1,2),我们通常约定的基向量是 i = (1,0) 与 j = (0,1); 因此:

a = -1i 2j = -1(1,0) 2(0,1) = (-1 0,0 2) = (-1,2);

而矩阵变换的,其实便是通过矩阵转换了基向量,从而完成了向量的变换;

例如上面的栗子,把a向量通过矩阵(1,2,3,0)进行变换,此时基向量i(1,0)变换成(1,-2)j(0,1)变换成(3,0),沿用上面的推导,则

a = -1i 2j = -1(-1,2) 2(3,0) = (5,-2);

如下图所示:
A图表示变换之前的坐标系,此时a=(-1,2),通过矩阵变换后,基向量i,j的变换引起了坐标系的变换,变成了下图B,因此a向量由(-1,2)变换成了(5,-2)

其实向量与坐标系的关联不变(a = -1i 2j),是基向量引起坐标系变化,然后坐标系沿用关联导致了向量的变化;

分分快三计划 50

MatrixTo

然而matrix虽然强大,但可读性却不好,而且我们的写入是通过translate/rotate/scale的属性,然而通过getComputedStyle读取到的 transform却是matrix:

transform:matrix(1.41421, 1.41421, -1.41421, 1.41421, -50, -50);

请问这个元素发生了怎么样的变化?。。这就一脸懵逼了。-_-|||

因此,我们必须要有个方法,来将matrix翻译成我们更为熟悉的translate/rotate/scale方式,在理解了其原理后,我们便可以着手开始表演咯~

我们知道,前4个参数会同时受到rotatescale的影响,具有两个变量,因此需要通过前两个参数根据上面的转换方式列出两个不等式:

cos(θ·π/180)*s=1.41421;

sin(θ·π/180)*s=1.41421;

将两个不等式相除,即可以轻松求出θs了,perfect!!函数如下:

分分快三计划 51

结合代码

其实CSS的transform等变换便是通过矩阵进行的,我们平时所写的translate/rotate等语法类似于一种封装好的语法糖,便于快捷使用,而在底层都会被转换成矩阵的形式。例如transform:translate(-30px,-30px)编译后会被转换成transform : matrix(1,0,0,1,30,30);

通常在二维坐标系中,只需要 2X2 的矩阵便足以描述所有的变换了, 但由于CSS是处于3D环境中的,因此CSS中使用的是 3X3 的矩阵,表示为:

分分快三计划 52

其中第三行的0,0,1代表的就是z轴的默认参数。这个矩阵中,(a,b) 即为坐标轴的 i基,而(c,d)既为j基,ex轴的偏移量,fy轴的偏移量;因此上栗便很好理解,translate并没有导致i,j基改变,只是发生了偏移,因此translate(-30px,-30px) ==> matrix(1,0,0,1,30,30)~

所有的transform语句,都会发生对应的转换,如下:

// 发生偏移,但基向量不变; transform:translate(x,y) ==> transform:matrix(1,0,0,1,x,y) // 基向量旋转; transform:rotate(θdeg)==> transform:matrix(cos(θ·π/180),sin(θ·π/180),-sin(θ·π/180),cos(θ·π/180),0,0) // 基向量放大且方向不变; transform:scale(s) ==> transform:matrix(s,0,0,s,0,0)

1
2
3
4
5
6
7
8
// 发生偏移,但基向量不变;
transform:translate(x,y) ==> transform:matrix(1,0,0,1,x,y)
 
// 基向量旋转;
transform:rotate(θdeg)==> transform:matrix(cos(θ·π/180),sin(θ·π/180),-sin(θ·π/180),cos(θ·π/180),0,0)
 
// 基向量放大且方向不变;
transform:scale(s) ==> transform:matrix(s,0,0,s,0,0)

translate/rotate/scale等语法十分强大,让我们的代码更为可读且方便书写,但是matrix有着更强大的转换特性,通过matrix,可以发生任何方式的变换,例如我们常见的镜像对称transform:matrix(-1,0,0,1,0,0);

分分快三计划 53

手势原理

接下来我们将上面的函数用到实际环境中,通过图示的方式来模拟手势的操作,简要地讲解手势计算的原理。希望各位大神理解这些基础的原理后,能创造出更多炫酷的手势,像我们在mac触控板上使用的一样。

下面图例:

圆点: 代表手指的触碰点;

两个圆点之间的虚线段: 代表双指操作时组成的向量;

a向量/A点:代表在 touchstart 时获取的初始向量/初始点;

b向量/B点:代表在 touchmove 时获取的实时向量/实时点;

坐标轴底部的公式代表需要计算的值;

初始位置

维护外部的这个位置数据,如果初始值像上述那样直接取0,则遇到使用css设置了transform属性的元素便无法正确识别了,会导致操作元素开始时瞬间跳回(0,0)的点,因此我们需要初始去获取一个元素真实的位置值,再进行维护与操作。此时,便需要用到上面我们提到的getComputedStyle方法与matrixTo函数:

// 获取css transform属性,此时得到的是一个矩阵数据; // transform:matrix(1.41421,1.41421,-1.41421,1.41421,-50,-50); let style = window.getComputedStyle(el,null); let cssTrans = style.transform || style.webkitTransform; // 按规则进行转换,得到: let initTrans = _.matrixTo(cssTrans); // {x:-50,y:-50,scale:2,rotate:45}; // 即该元素设置了:transform:translate(-50px,-50px) scale(2) rotate(45deg);

1
2
3
4
5
6
7
8
9
10
// 获取css transform属性,此时得到的是一个矩阵数据;
// transform:matrix(1.41421,1.41421,-1.41421,1.41421,-50,-50);
let style = window.getComputedStyle(el,null);
let cssTrans = style.transform || style.webkitTransform;
 
// 按规则进行转换,得到:
let initTrans = _.matrixTo(cssTrans);
 
// {x:-50,y:-50,scale:2,rotate:45};
// 即该元素设置了:transform:translate(-50px,-50px) scale(2) rotate(45deg);

Pinch(双指缩放)

分分快三计划 54

上图是双指缩放的模拟图,双指由a向量放大到b向量,通过初始状态时的a向量的模与touchmove中获取的b向量的模进行计算,便可得出缩放值:

// touchstart中计算初始双指的向量模; let vector1 = getVector(secondPoint, startPoint); let pinchStartLength = getLength(vector1); // touchmove中计算实时的双指向量模; let vector2 = getVector(curSecPoint, curPoint); let pinchLength = getLength(vector2); this._eventFire('pinch', { delta: { scale: pinchLength / pinchStartLength, }, origin: ev, });

1
2
3
4
5
6
7
8
9
10
11
12
13
// touchstart中计算初始双指的向量模;
let vector1 = getVector(secondPoint, startPoint);
let pinchStartLength = getLength(vector1);
 
// touchmove中计算实时的双指向量模;
let vector2 = getVector(curSecPoint, curPoint);
let pinchLength = getLength(vector2);
this._eventFire('pinch', {
    delta: {
        scale: pinchLength / pinchStartLength,
    },
    origin: ev,
});

致谢

  • 张鑫旭: 获取元素CSS值之getComputedStyle方法熟悉
  • 张鑫旭:理解CSS3 transform中的Matrix(矩阵)
  • AlloyTeam团队的AlloyFinger
  • hcysunyangd: 从矩阵与空间操作的关系理解CSS3的transform
  • 线性代数的理解 学完再看觉得自己弱爆了

    1 赞 6 收藏 1 评论

分分快三计划 55

基础数学知识函数

我们常见的坐标系属于线性空间,或称向量空间(Vector Space)。这个空间是一个由点(Point) 和 向量(Vector) 所组成集合;

旋转角度

通过数量积公式我们可以推到求出两个向量的夹角:

cosθ=(x1·x2 y1·y2)/(|a|·|b|);

然后通过共线定理我们可以判断出旋转的方向,函数定义为:

分分快三计划 56

运动增量

由于touchmove事件是个高频率的实时触发事件,一个拖动操作,其实触发了N次的touchmove事件,因此计算出来的值只是一种增量,即代表的是一次 touchmove事件增加的值,只代表一段很小的值,并不是最终的结果值,因此需要由mtouch.js外部维护一个位置数据,类似于:

// 真实位置数据; let dragTrans = {x = 0,y = 0}; // 累加上 mtouch 所传递出的增量 deltaX 与 deltaY; dragTrans.x = ev.delta.deltaX; dragTrans.y = ev.delta.deltaY; // 通过 transform 直接操作元素; set($drag,dragTrans);

1
2
3
4
5
6
7
8
9
//    真实位置数据;
let dragTrans = {x = 0,y = 0};
 
// 累加上 mtouch 所传递出的增量 deltaX 与 deltaY;
dragTrans.x = ev.delta.deltaX;
dragTrans.y = ev.delta.deltaY;
 
// 通过 transform 直接操作元素;
set($drag,dragTrans);

HTML5中手势原理分析与数学知识的实践

2017/08/08 · HTML5 · 1 评论 · 手势

原文出处: 郭东东   

HTML5中手势原理分析与数学知识的实践

本文由分分快三计划发布,转载请注明来源

关键词: 分分快三计划 HTML5 高级 UIView 玩法