这篇是总结上一周在开发中遇到的问题,以及针对这个问题又进一步做了研究后得出的结论和经验。一来是帮助自己深入理解UIKit的工作机制,形成自己的体系风格;二来是尽量帮助大家避过我踩过的坑.
抛出问题
目前我正在做照片编辑器的软件(photo collage), 简单来说就是让用户编辑,组合自己的照片,然后上传到walgreens的服务器进行打印,之后用户去店里去取。和国内美图秀秀这类软件还是有一些区别。既然是照片编辑,那么旋转,缩放,平移这些对相片基本的操作就必不可少,但也是这些看上去不起眼的功能往往会给你带来不小的困扰. 问题背景如下:
如图所示,这是App的 "Editing View" 我只把中间的主要部分重画了下,用来阐述问题。灰色部分是最外层的container view, 姑且叫它 “root container view”(此环境下); A, B, C各自代表一个container view,并且在每个container view中,有一个imageView,我们可以对这个imageView进行一些基本操作,旋转,平移,缩放等。所以,在imageView上,就被添加了3个gesture recognizers。OK, 问题到目前为止还是很清晰的,好,我们接着往下走。在A, B, C这3个container上,也加了一个gesture, 叫“longpress gesture”用来处理用户长按操作,这个操作是用作交换A,B,C三个container view位置的。当用户长按某个container view,触发了longPressure操作后就可以进行拖拽以交换位置。
啰嗦了这么多把背景讲完,下面说下问题。老板突然要求要加入3D force touch,以便outstanding。本身加入force touch是一个很简单的工作(quick actions, peek and pop, 以及新属性应用), 但是背靠万恶的“legacy code”,添加新功能引入bug是不可避免的了。老板的要求是使用3D force touch触发之前longpress的操作,听上去很简单,只是把触发操作改了嘛其他的不动就好了,最多extract几个函数出来嘛,此时我的心情是:
开始解决问题
这里讲述我解决问题时的心路历程,如果您着急解决问题可直接跳过这部分,到第三部分。
OK, 那就让我们来看下怎么触发3D force touch。 在iOS9中, Apple为UITouch增加了2个新的属性: force和maximumPossibleForce。force代表的就是你的按压力度,通常的默认值是1,由系统决定,被当做一个average touch。其真实值是0~6.66666...., 很多6....., 有些国外的网友测试过,6.66666....就是385克,这也是**maximumPossibleForce****的值. Apple提供范围如此之大的上下限就是让开发者可以积极利用force这个属性,开发出精准交互的App。
Ok,那我们在哪里调用这个touch对象呢?毫无疑问,我们首先想到的就是Respond Chain API, 那一系列的touchXXX方法。force touch是一个持续的过程,所以我们并不在touchBegan:withEvent中做force的检测,而是在touchMoved:withEvent中判断force的变化是否达到我们所设置的阈值. 当然,UIResponder中还有一系列pressXXX方法,例如pressesBegan:withEvent, 但这些是给physical button点击时使用的,例如Apple TV的遥控器等,对于普通的surface touch,还是touchXXX方法响应。
这时候,问题来了,在响应链API中,我们获取到的touch对象上的view是最底层的那个view,也就是说,当我们长按container view触发移动操作时,我们拿到的touch.view实际上是最下面的imageView, 而不是对应的container view。这会带来什么问题呢?因为image view本身就绑定了3个gesture,这些gesture本身就能响应用户的操作,而现在新加入的touchXXX系列方法也同样响应UITouch事件,处理不好就会有冲突,甚至崩溃,这也是为什么我本篇题目定位为混合编程的原因。
我首先想到的解决办法就是将所选imageView对应的container view找出来,之后再container view上加效果,做移动。但马上发现其实这样做是不可取的,因为touch.view返回值就是imageView,这个在整个touch事件周期中是不会改变的,而且在touchXXX API中,你能移动的必须是,也只能是一开始选中的imageView。那好,那我们可以做个测试,我把imageView提取了出来,加到了root container中,来做移动,这时候,imageView的确可以自由移动了,但是当你释放手指的时候,touchEnded:withEvent方法并没有被调用, 导致结束函数无法被触发。具体的原因我猜测是因为imageView被我强行加入到了顶层container,破坏了hitTest链,也就是响应链信息被破坏,导致touchEnded:withEvent无法被调用,没有对象触发它。当我们移走imageView,再在其对应的空白container view上点击下的话,touchEnded:withEvent会被调用。
这时候,我的心情....
真正解决问题
下了班和朋友讨论了下,他提供了一个折衷的方案(又印证了经验丰富的老司机感觉不会错的观点---我说的): 使用touchMoved:withEvent来检测force touch,触发操作; 之后使用UIPangestureRecogonizer对象进行之后的所有操作. 我听完后整个人就开朗了~~~~
第二天,我要把这个theory付诸实际,开始调试代码,在touchMoved:withEvent:加入force touch的触发判断,之后做一个标记(boolean值)记录force touch已被触发。OK, 开始运行程序,长按后,的确触发了force touch但是当我拖拽的时候,对应的container view并没有移动,这就奇怪了,pangesture recogonizer已经加到了每个container view上面啊,为什么不触发呢?思前想后,八成又是响应链API和手势识别混用的后果,但Apple应该也给出了控制它们响应的方法。所以我打开了UIGestureRecognizer的头文件,发现在UIGestureRecognizerDelegate中有一个方法:gestureRecognizerShouldBegin,该方法控制着gesture是否该响应的返回值, 而我之前的拖拽无响应是不是和这个delegate method有关呢?我在当前的view contrller实现了这个方法,果不其然,当返回为YES时,panGesture顺利工作了!但是,container view里面的imageView也跟着滑动,这个是不需要的。所以,当触发force touch时,让imageView的gestureRecognizerShouldBegin:返回空即可(可以暴露imageView一个public attr给外部类,当触发force touch时, 将这个attr赋值为YES, 至于gestureRecognizerShouldBegin:, 系统在每次用户滑动时都会触发,所以你只需在该函数开头加入正确的判断即可,即手势类型判断和标记为判断)。
OK,既然说到了gestureRecognizerShouldBegin:我们就再深入研究下它的调用。该方法的调用可不是在实现该方法的类里面调用一次那么简单。这样说可能还有些晦涩,我们来举个栗子:
图中有3中颜色, 分别代表3个不同大小的view,小的view是大的view的subview。在每个view上,我都加上了tap gesture recognizer,并且在每个类里面,都实现了gestureRecognizerShouldBegin:. 运行程序后,我们在最内层的蓝色view上点击下,程序立刻在蓝色view的gestureRecognizerShouldBegin:停下(打了断点), 但这时候,tap gesture所持有的view却不是蓝色的view,而是橙色和外层绿色或者蓝色中的某一个(随机的,我测试过10次以上了...); OK, 为了之后方便讲解,我们假定tap gesture第一次点击持有的view就是蓝色view,当我们点击继续后(声明: 3个view的gestureRecognizerShouldBegin:均返回YES),橙色view的gestureRecognizerShouldBegin:被调用了,点击继续后,又跳回蓝色view的gestureRecognizerShouldBegin:方法中,再点击继续,则跳到绿色view的gestureRecognizerShouldBegin:方法。综上来说,最底层的subview的gestureRecognizerShouldBegin:方法被调用了3次, 有且只有最底层的view的gestureRecognizerShouldBegin:返回为YES时,上层的gestureRecognizerShouldBegin:才会被调用。这种机制会带来什么后果或者说bug呢? 如果你在最底层的subview中的gestureRecognizerShouldBegin:方法判断错误的话, 一旦其放回NO, 上层的gesture都不会被触发,手势识别全部不会响应.听上去好严重的样子, 那有没其他方法可以代替呢?有的,那就是shouldReceiveTouch:这个方法, 这个方法会在touchBegan:withEvent: 之前调用, 目的在于告诉gesture recogonizer是否应该接收touch事件,如果返回NO,则不接收。这个方法在每个view只会调用一次,不存在底层subview调用多次的情况。
UIGestureRecognizerDelegate还有其他的函数,但都比较简单,使用场景比较单一大家可以自行看下.
好,回到正题。解决了触发pan gesture的问题之后,新的问题来了。当用户仅触发了force touch并不移动时, 系统不会发送touchEnded:withEvent:回来, 导致收尾方法无法被调用,造成用户体验上的缺失.那原因是什么的?既然是gesture recogonizer出了问题,那就去它的头文件看看。我发现了2个比较有意思的属性: delaysTouchesBegan和delaysTouchesEnded。delaysTouchesBegan是用来告诉响应链你的touchBegan:withEvent:别调用,一切让我的gesture识别器来此处理,这个属性默认值是NO; delaysTouchesEnded这个属性默认值是YES,告诉响应链你的touchEnded:withEvent:别管,我gesture识别器来做。这个属性就是造成之前提到bug的罪魁祸首, 在往view上天剑gesture时,将此属性置为NO即可。当然那你也不用担心,当触发pan gesture时,结束函数就是gesture控制的,不会在调用touchEnded:withEvent:方法了。
总结
好了,到此为止,问题解决了。让我对响应链和gesture识别器又多了一层的了解。