来源:杨萧玉(@杨萧玉HIT) 程序员
连接:http://t.cn/RtsDfXa架构
若是一个页面上包含着不少视图,并且界面上业务逻辑比较复杂,那么手势响应冲突或者错乱很容易发生。这时就得猥琐点啦,见招拆招。app
处理界面多变引起的手势冲突优化
分析问题ui
界面变化多意味着什么?负责的业务逻辑?不一样机型适配?这都不是我要首先去重点考虑的,但有一点很重要,那就是要有一个完善的状态机!要透过现象看本质:手势冲突的缘由?难道是由于那几个 UIGestureRecognizerDelegate 方法的实现有问题?或者是由于跨层级传递事件在 hitTest:withEvent: 里的业务逻辑太复杂没理清?其实这些就算都能弄得很明白,界面内容一变化就容易出问题。更有可能为了快速响应用户的操做而让一些视图常驻内存,而不是每次从新建立和添加,这增长了界面内容的复杂度。spa
举个栗子,我想让用户发图片前能够对图片进行编辑,好比加段文字、贴纸、滤镜、涂鸦之类的,甚至能够裁剪和加背景音乐。暂且不说如何展现编辑后的图片,但就编辑的界面就很复杂,毕竟好多种编辑模式要在同一个界面中完成。这少不了各类编辑模式入口的按钮,也少不了每种编辑模式对界面视图层级的叠加。起码滤镜要单独一层吧,每一个贴纸和文字都是个视图,涂鸦也要一层视图。裁剪时整个图片包括编辑时添加的内容都要跟着一块儿缩放和旋转,切换滤镜须要滑动,文字和贴纸都要缩放平移旋转等操做。更别提添加文字、贴纸和背景音乐时要覆盖一个全屏的界面(不用新的 controller,而是添加视图),让用户编辑文字或选择素材。这些业务都在一个 controller 里放着,好多层视图叠加,并且变幻莫测。在什么时刻该响应哪一个视图的哪一个手势,靠什么判断?答案就是:状态机设计
其实在 QQ 日迹中,状态机能解决的更多的是界面错乱的问题,但界面一旦错乱必将对手势判断带来致命影响。就算界面不错乱,也须要在 UIGestureRecognizerDelegate 方法或 hitTest:withEvent: 中知晓当前界面处于何种状态,而后才能准确判断选择哪一个手势或哪一个视图。这里展开叙述下我对将来可使用状态机解决 UI 错乱以及所以而引起手势冲突的构想。orm
使用状态机的构想方案对象
能够认为每种编辑模式下都是一种状态,编辑完成以后也是种状态。还要考虑到初始状态或者无状态的状况。用户对图片上的贴纸和文字等元素进行操做时确定也要设定一种状态。总之状态不求多,但必定要面面俱到无遗漏,要根据当前界面操做设计状态。某种状态下可能还会有子状态,好比涂鸦模式下可能会有画笔、橡皮擦、马赛克,并能选择粗细之类的功能。这些都属于涂鸦模式下界面中的其余小功能,若是把这些功能的对应的状态跟其余几种编辑模式对应的状态放在一块儿,能保证惟一性的话倒不是说不能够,但很不合适。blog
每种状态都要规定它的『下一个状态』的集合,好比涂鸦模式下可能会进入到编辑完成状态,也可能返回到初始状态,也可能进入到裁剪状态。。。这些规则要照着产品经理指定的业务逻辑来,作到调理清晰。制定好每种状态的『下一个状态』的集合后,一张有向图就会展示出来了,规则定了就好办了。不要把这些状态简单理解成『一个枚举』,要用面向对象的思想来实现。好比能够创建个表示状态的基类,再弄个 isValidNextState: 方法来判断输入的状态是否能当作此状态的『下一个状态』。苹果的 GameplayKit 中的状态机(GKStateMachine)就是个很不错的例子。
下一步就是状态的响应,在状态转换时驱动界面元素的变化。什么?不是应该在点击按钮时对界面作变动么?这种思惟很局限,也是致使代码复用不高和 bug 频出的缘由。可以改变编辑模式的不必定只有按钮点击,这要根据产品的业务。因此应该让界面变动依赖于状态的变化,这样更集中统一,不容易出差错。(但这样的缺点可能就是产品经理要求上报用户行为时没法获知用户何种操做致使状态变化,这里只能经过在状态类中加标志位判断了。)
最关键的是在正确的位置添加状态切换的代码,必定要覆盖全面毫无遗漏。这是保证整个状态机运行的关键!
说了这么多,也没看出状态机跟手势有多大关系啊?直观点讲,在涂鸦状态下是不会响应双指操做的手势的,由于只有单个手指的 Pan 和 Tap 手势;而在操做文字和贴纸的状态下 Pinch、Rotation 和 Pan 是能够同时响应的,由于用户能够旋转缩放视图的同时挪动视图位置,而 Tap 手势此时可能还会赋有其余的功能。总之状态机将复杂的业务逻辑所对应的手势操做划分开,提供了准确惟一的判断。
若是不使用状态机,(打个比方)而是根据界面上某个按钮的 selected 或者某个视图的 hidden 属性来判断下一步的操做,那确定会出大乱子。由于 UI 控件的状态不可靠,可以改变它们的因素不少,并且会有多个 UI 状态同时存在致使冲突。惟有状态机紧紧把我在程序员的手里,惟一且准确。
处理界面复杂引起的手势错乱
情景还原
『你看贴纸这么多手指又太大缩放不灵敏真不怪我啊,臣妾真的办不到啊!』
『哎呀,原本想旋转某个贴纸的,结果两个手指分别在另外两个贴纸上。这么多小贴纸放这么密用户好变态啊!』
。。。真是乱,想操做 A 视图却意外操做了 B 视图。。。
分析问题
对手势统一处理和分发
要是给每一个视图内容都单独添加一套 Tap、Pan、LongPress、Pinch、Rotation 手势那真是找死啊,手势不错乱才怪呢!别再把手势错乱归结于界面上视图多,要怪就怪添加手势的姿式不对!
当界面内容数量较多时仍是要尊崇大一统的思想,把各类手势全都添加到底层的全屏视图上,而后统一处理和分发结果。由于每种手势只有一个且都加在了底层视图,因此不会发生不一样视图间的手势错乱。而不一样种手势之间的冲突就须要在 UIGestureRecognizerDelegate 中根据业务逻辑来解决了。
那么该如何判断哪一个视图响应了手势的操做呢?用户最但愿的确定是最顶层的且距离手指最近的视图。这里难在如何选择距离手指最近的视图。
计算响应手势的视图
能够经过 locationInView: 获取手势的坐标,但这里决不能简单地计算手势坐标到视图 center 的距离并选取最近的视图。这里须要检测手势坐标处于哪一个视图的范围内,包括『在视图区域内』(红色)和『在视图周围区域』(橙色):
策略是先看手势坐标处于哪些视图的『视图区域』中,若是没找到,就再扩大查找范围至『周围区域』。最后若是有多个视图知足要求,就选择最顶层的视图。若是没有任何视图知足要求,能够不作任何处理;也能够根据产品策略对界面上惟一的视图进行操做。这里就看业务怎么规定的了。
至于『周围区域』该如何划定,具体参数就看产品制定的策略进行微调了。总之传入一个 UIEdgeInsets 就能搞定。
在用代码实现的时候能够优化逻辑来减小遍历的时间复杂度:从最顶层视图到最底层视图开始遍历,若是手势坐标命中『视图区域』内,则直接得出结果。不然若是手势坐标命中『周围区域』内,就计算手势到视图中心距离并在遍历完成后获得距离最近的视图。
解决问题
处理 Pinch 手势
在视图被缩放时,通常是改变 transform 属性。关于 CGAffineTransform 的知识这里再也不赘述。
分辨率
当对含有矢量内容的视图进行缩放时会有模糊和锯齿出现,这时递归须要改变 UIView 的 contentScaleFactor 和 CALayer 的 contentsScale 属性:
- (void)updateForZoomScale:(CGFloat)zoomScale {
CGFloat screenAndZoomScale = zoomScale * [UIScreen mainScreen].scale;
// Walk the layer and view hierarchies separately. We need to reach all tiled layers.
[self applyScale:screenAndZoomScale toView:self];
[self applyScale:screenAndZoomScale toLayer:self.layer];
}
- (void)applyScale:(CGFloat)scale toView:(UIView *)view {
view.contentScaleFactor = scale;
for (UIView *subview in view.subviews) {
[self applyScale:scale toView:subview];
}
}
- (void)applyScale:(CGFloat)scale toLayer:(CALayer *)layer {
layer.contentsScale = scale;
for (CALayer *sublayer in layer.sublayers) {
[self applyScale:scale toLayer:sublayer];
}
}
坐标
视图的 transform 属性是不会修改视图的 bounds 的,但 frame 做为计算属性仍是会变化的。也就是说不管视图放大了多少倍,视图内部的子视图的 frame 不会变。
总之,transform 属性改变的是视图的 frame,而 bounds 和子视图的 frame 都不会变。也就是视图内部的坐标系不会改变。记住这点,颇有用。
上图展现的是缩放后的坐标变换,也一样适用于旋转。都是相对坐标系的知识罢了。
处理 Rotation 手势
以前一直用『视图区域』而不直接用 frame 来描述手势判断依据,是由于当视图旋转(90°倍数除外)以后 frame 并不等于『视图区域』:
也就是说若是按照 frame 来判断『视图区域』是偏大的,会遮挡住其余视图。因此我专门写了个方法用于判断某个点是否在『视图区域』内,还提供了 UIEdgeInsets 参数用于知足判断『周围区域』的要求:
/**
* 判断某个点是否在视图区域内,针对 transform 作了转换计算,并提供 UIEdgeInsets 缩放区域的参数
*
* @param point 要判断的点坐标
* @param view 传入的视图,必定要与本视图处于同一视图树中
* @param insets UIEdgeInsets参数能够调整判断的边界
*
* @return BOOL类型,返回点坐标是否位于视图内
*/
- (BOOL)checkPoint:(CGPoint) point inView:(UIView *)view withInsets:(UIEdgeInsets)insets
{
// 将点坐标转化为视图内坐标系的点,消除 transform 带来的影响
CGPoint convertedPoint = [self convertPoint:point toView:view];
CGAffineTransform viewTransform = view.transform;
// 计算视图缩放比例
CGFloat scale = sqrt(viewTransform.a * viewTransform.a + viewTransform.c * viewTransform.c);
// 将 UIEdgeInsets 除以缩放比例,以便获得真实的『周围区域』
UIEdgeInsets scaledInsets = (UIEdgeInsets){insets.top/scale,insets.left/scale,insets.bottom/scale,insets.right/scale};
CGRect resultRect = UIEdgeInsetsInsetRect(view.bounds, scaledInsets);
// 判断给定坐标点是否在区域内
if (CGRectContainsPoint(resultRect, convertedPoint)) {
return YES;
}
return NO;
}
通过此方法处理后会使得区域判断更准确,那些旋转过的视图带来的手势失效也得以解决。
总结
其实若是全部手势都交给一个底层视图统一处理的话,上层那一坨视图是不须要响应触摸事件的,有些甚至能够用 Layer 来作。
UIGestureRecognizerDelegate 和 hitTest:withEvent: 的用法官方文档中有详细阐述,可以解决手势问题的前提是熟悉文档,而后才是一些思想和架构层面的解决方案。好比 Tap 手势要先让 Pan 手势失败之类的手势冲突就能够用 UIGestureRecognizerDelegate 处理,再也不列举。
我碰到的应用场景有限,经验不够多,还请你们补充经验!
Reference
http://stackoverflow.com/questions/5927223/scaling-uitextview-using-contentscalefactor-property