來源:楊蕭玉(@楊蕭玉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