處理手勢沖突和錯亂的一點經驗
如果一個頁面上包含著很多視圖,而且界面上業務邏輯比較復雜,那么手勢響應沖突或者錯亂很容易發生。這時就得猥瑣點啦,見招拆招。
處理界面多變引發的手勢沖突
分析問題
界面變化多意味著什么?負責的業務邏輯?不同機型適配?這都不是我要首先去重點考慮的,但有一點很重要,那就是要有一個完善的狀態機!要透過現象看本質:手勢沖突的原因?難道是因為那幾個 UIGestureRecognizerDelegate 方法的實現有問題?或者是因為跨層級傳遞事件在 hitTest:withEvent: 里的業務邏輯太復雜沒理清?其實這些就算都能弄得很明白,界面內容一變化就容易出問題。更有可能為了快速響應用戶的操作而讓一些視圖常駐內存,而不是每次重新創建和添加,這增加了界面內容的復雜度。
舉個栗子,我想讓用戶發圖片前可以對圖片進行編輯,比如加段文字、貼紙、濾鏡、涂鴉之類的,甚至可以裁剪和加背景音樂。暫且不說如何展示編輯后的圖片,但就編輯的界面就很復雜,畢竟好多種編輯模式要在同一個界面中完成。這少不了各種編輯模式入口的按鈕,也少不了每種編輯模式對界面視圖層級的疊加。起碼濾鏡要單獨一層吧,每個貼紙和文字都是個視圖,涂鴉也要一層視圖。裁剪時整個圖片包括編輯時添加的內容都要跟著一起縮放和旋轉,切換濾鏡需要滑動,文字和貼紙都要縮放平移旋轉等操作。更別提添加文字、貼紙和背景音樂時要覆蓋一個全屏的界面(不用新的 controller,而是添加視圖),讓用戶編輯文字或選擇素材。這些業務都在一個 controller 里放著,好多層視圖疊加,而且變幻莫測。在什么時刻該響應哪個視圖的哪個手勢,靠什么判斷?答案就是: 狀態機
其實在 QQ 日跡中,狀態機能解決的更多的是界面錯亂的問題,但界面一旦錯亂必將對手勢判斷帶來致命影響。就算界面不錯亂,也需要在 UIGestureRecognizerDelegate 方法或 hitTest:withEvent: 中知曉當前界面處于何種狀態,然后才能準確判斷選擇哪個手勢或哪個視圖。這里展開敘述下我對未來可以使用狀態機解決 UI 錯亂以及因此而引發手勢沖突的構想。
使用狀態機的構想方案
可以認為每種編輯模式下都是一種狀態,編輯完成之后也是種狀態。還要考慮到初始狀態或者無狀態的情況。用戶對圖片上的貼紙和文字等元素進行操作時肯定也要設定一種狀態。總之狀態不求多,但一定要面面俱到無遺漏,要根據當前界面操作設計狀態。某種狀態下可能還會有子狀態,比如涂鴉模式下可能會有畫筆、橡皮擦、馬賽克,并能選擇粗細之類的功能。這些都屬于涂鴉模式下界面中的其他小功能,如果把這些功能的對應的狀態跟其他幾種編輯模式對應的狀態放在一起,能保證唯一性的話倒不是說不可以,但很不合適。
每種狀態都要規定它的『下一個狀態』的集合,比如涂鴉模式下可能會進入到編輯完成狀態,也可能返回到初始狀態,也可能進入到裁剪狀態。。。這些規則要照著產品經理指定的業務邏輯來,做到調理清晰。制定好每種狀態的『下一個狀態』的集合后,一張有向圖就會展現出來了,規則定了就好辦了。不要把這些狀態簡單理解成『一個枚舉』,要用面向對象的思想來實現。比如可以建立個表示狀態的基類,再弄個 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.
[selfapplyScale:screenAndZoomScaletoView:self];
[selfapplyScale:screenAndZoomScaletoLayer:self.layer];
}
- (void)applyScale:(CGFloat)scaletoView:(UIView *)view {
view.contentScaleFactor = scale;
for (UIView *subview in view.subviews) {
[selfapplyScale:scaletoView:subview];
}
}
- (void)applyScale:(CGFloat)scaletoLayer:(CALayer *)layer {
layer.contentsScale = scale;
for (CALayer *sublayer in layer.sublayers) {
[selfapplyScale:scaletoLayer: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) pointinView:(UIView *)viewwithInsets:(UIEdgeInsets)insets
{
// 將點坐標轉化為視圖內坐標系的點,消除 transform 帶來的影響
CGPoint convertedPoint = [selfconvertPoint:pointtoView: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 處理,不再列舉。
來自:http://ios.jobbole.com/89535/