UIResponder(響應對象)
An abstract interface for responding to and handling events.
一個UIResponder
類為那些需要響應并處理事件的對象定義了一組接口片酝。在iOS
中不是任何對象都能處理事件, 只有繼承了UIResponder
的對象才能接收并處理事件汉额,稱為響應者對象液南。UIApplication
背镇,UIViewController
,UIView
都繼承自UIResponder
钝计,因此他們都是響應者對象,茉帅,都能夠接收并處理事件。這意味著所有的視圖(all views)和大多數(shù)的關鍵視圖控制器對象都是響應者宾添。但是要注意核心動畫中的層(layer)不是響應者船惨。
繼承自UIResponder
的類能處理事件是由于UIResponder
內部提供了以下方法:
// 觸摸事件
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesEstimatedPropertiesUpdated:(NSSet<UITouch *> *)touches NS_AVAILABLE_IOS(9_1);
// 按壓事件
- (void)pressesBegan:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event NS_AVAILABLE_IOS(9_0);
- (void)pressesChanged:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event NS_AVAILABLE_IOS(9_0);
- (void)pressesEnded:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event NS_AVAILABLE_IOS(9_0);
- (void)pressesCancelled:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event NS_AVAILABLE_IOS(9_0);
// 傳感器事件
- (void)motionBegan:(UIEventSubtype)motion withEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(3_0);
- (void)motionEnded:(UIEventSubtype)motion withEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(3_0);
- (void)motionCancelled:(UIEventSubtype)motion withEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(3_0);
// 遠程控制事件
- (void)remoteControlReceivedWithEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(4_0);
分發(fā)機制(Hit-Testing)
iOS
使用hit-testing
來找到觸摸點所在的視圖。hit-testing
將會檢測是否觸摸事件在相關視圖的顯示區(qū)域之內缕陕。如果在粱锐,將遞歸檢測當前視圖的所有子視圖。視圖層級中最底層的view
如果包含觸摸點將成為hit-test view
扛邑。在iOS
確認了hit-test view
之后怜浅,將傳遞觸摸事件給對應的視圖進行處理。
為了解釋上面蔬崩,下面看一下官方例子:用戶觸摸視圖view E
恶座,iOS
將有序查找子視圖,找到hit-test view
1:由下圖可知沥阳,觸摸位于視圖A的區(qū)域之內跨琳,所以會對B,C進行檢測
2:如果觸摸事件不在視圖B區(qū)域中,但位于視圖區(qū)域C沪袭,將對視圖C的子視圖D,E進行檢測
3:如果觸摸事件不在視圖D區(qū)域中湾宙,但位于視圖區(qū)域E中,又因為視圖E是視圖層級結構中最底層的視圖冈绊,所以視圖E將成為hit-test view
Hit-testing returns the subview that was touched
hitTest:withEvent:
方法會根據(jù)給定觸摸點(CGPoint)
和事件對象(UIEvent)
兩個參數(shù)侠鳄,返回點擊的視圖(hit test view)
。
1死宣、該方法首先會調用pointInside:withEvent:
方法,如果hitTest:withEvent:
方法中所傳遞的參數(shù)point
點是位于視圖之內伟恶,pointInside:withEvent:
方法將返回true,然后毅该,在返回true
的所有子視圖上將遞歸調用hitTest:withEvent:
方法博秫。
2、如果hitTest:withEvent:
方法中傳遞的point
點不在視圖顯示區(qū)域之內眶掌,第一次調用pointInside:withEvent:
方法將返回false
挡育,那么該點將被忽略,hitTest:withEvent:
方法將返回nil
朴爬。如果一個子視圖返回false
即寒,那么整個視圖層級都將被忽略,因為觸摸并不在子視圖當中,所以子視圖的子視圖同樣也不會發(fā)生觸摸事件母赵。
總結hitTest處理流程
調用當前view
的pointInside:withEvent:
方法來判定觸摸點是否在當前view
內部逸爵,如果返回false
,則hitTest:withEvent:
返回nil
凹嘲;如果返回true
师倔,則向當前view
內的subViews
發(fā)送hitTest:withEvent:
消息,所有subView
的遍歷順序是從數(shù)組的末尾向前遍歷周蹭,直到有subView
返回非空對象或遍歷完成趋艘。如果有subView
返回非空對象,hitTest
方法會返回這個對象谷醉,如果每個subView
返回都是nil
致稀,則返回自己。
注意:
hitTest:withEvent:方法忽略隱藏(hidden=YES)的視圖俱尼,禁止用戶操作(userInteractionEnabled=YES)的視圖抖单,以及alpha級別小于0.01(alpha<0.01)的視圖。
hit-test view
將首先處理觸摸事件遇八,如果hit-test view
并不能夠處理事件矛绘,那么該事件將由視圖的響應者鏈進行查找,一直到系統(tǒng)找到能夠處理事件的對象刃永。
事件響應者鏈
許多類型的事件都依賴于響應者鏈進行事件的傳遞货矮。響應者鏈關聯(lián)著一系列的響應者對象,由第一個響應者對象開始一直到application
對象結束斯够,如果第一個響應者不能夠處理事件囚玫,事件將會被傳遞到響應者鏈中的下一個響應者對象。
第一響應者首先接收事件读规。代表性的就是:視圖是第一響應者對象抓督。一個對象要成為第一響應者需要做兩件事:
- 1、重寫
canBecomeFirstResponder
方法束亏,返回true
铃在,接收成為第一響應者信息 - 2、以及
becomeFirstResponder
方法碍遍,如果有必要定铜,對象能夠自己給自己發(fā)送信息
注意:
在對象被賦值成為第一響應者之前,確保APP已經(jīng)建立的對象圖形(object graph)怕敬。例如:我們可以在viewDidAppear:方法中調用becomeFirstResponder方法揣炕,但是,如果我們嘗試viewWillAppear:中賦值第一響應者东跪,我們的對象圖形可能還沒有建立畸陡,所以becomeFirstResponder方法將返回false矮烹。
響應者鏈遵守事件傳遞的具體路徑
如果最初的對象hit-test
視圖或者第一響應者(first responder)不能夠處理事件,UIKit將傳遞事件到響應者鏈中的下一個響應者罩锐。每一個響應者都會決定是否處理事件還是調用nextResponder
方法將事件傳遞給下級響應者。該過程一直到有一個響應者對象能夠處理事件或者沒有下級響應者為止卤唉。
下圖顯示了兩個APP配置下2種不同事件類型的路徑傳遞涩惑,事件傳遞路徑取決于具體的結構,所有的事件傳遞都遵守相同的起始桑驱。
左邊App事件所傳遞的路徑
1竭恬、初始視圖(initial view)
將嘗試著處理事件或消息。如果它不能處理事件熬的,將傳遞事件到自己的父視圖(superview)
痊硕,因為初始視圖并不是它所在視圖控制器中視圖層級的最頂部視圖
2、父視圖(superview)
將嘗試處理所傳遞的事件押框,如果父視圖不能夠處理事件岔绸,該事件將傳遞到它自己的父視圖,因為它仍然不是視圖層級的最頂部視圖
3橡伞、視圖控制器視圖層級中最頂部視圖(topmost view)將嘗試處理所傳遞的事件盒揉,如果最頂部視圖不能夠處理事件,它將傳遞事件給它的視圖控制器
4兑徘、視圖控制器(view controller)
將嘗試處理所傳遞事件刚盈,如果它不能夠處理事件,該事件將被傳遞到window
5挂脑、如果window
對象不能夠處理事件藕漱,它將傳遞事件到APP
全局單例對象(singleton app object).
6、如果app
對象不能夠處理事件崭闲,該事件將被放棄
右邊App事件的傳遞流程
1肋联、視圖傳遞事件到它所在的視圖控制器的視圖層級中,一直到最頂部視圖镀脂。
2牺蹄、最頂部視圖將傳遞事件到它的視圖控制器
3、視圖控制器將傳遞事件到它的最頂部視圖的父視圖薄翅。1~3步重復沙兰,直到找到根控制器
4、根視圖控制器將傳遞事件到window
對象
5翘魄、window
對象將傳遞事件到app
對象
唯一不同就在于鼎天,如果當前的ViewController
是有層級關系的,那么當子ViewController
不能處理事件時暑竟,它會將事件繼續(xù)往上傳遞斋射,直到傳遞到其Root ViewController
育勺,其他流程是一樣的。
事件的傳遞和響應鏈
傳遞鏈
由系統(tǒng)向離用戶最近的view
傳遞 UIKit –> active app’s event queue –> window –> root view –>……–>lowest view
響應鏈
由離用戶最近的view
向系統(tǒng)傳遞罗岖。initial view –> super view –> …..–> view controller –> window –> Application
hitTest:withEvent:的使用
- 1涧至、增加視圖的觸摸區(qū)域
比如:按鈕本身大小為20和20,由于太小不方便操作桑包,所以可以通過自定義UIButton
南蓬,重寫hitTest
方法,增加點擊區(qū)域哑了。下面實現(xiàn)每個方向增加40的可點擊區(qū)域赘方,具體實現(xiàn)代碼:
class MyButton: UIButton {
override func hitTest(point: CGPoint, withEvent event: UIEvent?) -> UIView? {
if !self.userInteractionEnabled || self.hidden || self.alpha == 0 {
return nil
}
// 擴大點擊區(qū)域
if CGRectContainsPoint(CGRectInset(self.bounds, -40, -40), point) {
for subview in self.subviews.reverse() {
let convertPoint = subview.convertPoint(point, fromView:self)
if let sview = subview.hitTest(convertPoint, withEvent: event) {
return sview
}
}
return self
}
return nil
}
}
hitTest:withEvent:
方法首先檢查視圖是否允許接收觸摸事件。視圖允許接收觸摸事件的條件是:
- 視圖不是隱藏的:
self.hidden == NO
- 視圖是允許交互的:
self.userInteractionEnabled ==true
- 視圖透明度大于:
0.01:self.alpha >0.01
- 視圖包含這個點:
pointInside:withEvent: ==true
如果視圖允許接收觸摸事件弱左,這個方法通過從后往前發(fā)送hitTest:withEvent:
消息給每一個子視圖來穿過接收者的子樹窄陡,直到子視圖中的一個返回nil。這些子視圖中的第一個返回的非nil就是在觸摸點下面的最前面的視圖拆火,被接收者返回跳夭。如果所有的子視圖都返回nil或者接收者沒有子視圖,那么返回接收者自己榜掌。否則优妙,如果視圖不允許接收觸摸事件,這個方法返回nil而根本不會傳遞到接收者的子樹憎账。因此套硼,hit-test可能不會訪問所有的視圖體系結構中的視圖。
測試功能:
func testExpandButtonClickArea(){
//為了便于觀察胞皱,添加一個背景視圖邪意,大小正好為100*100
let backgroundView = UIView(frame: CGRect(x: 60, y: 160, width: 100, height: 100))
backgroundView.backgroundColor = UIColor.purpleColor()
view.addSubview(backgroundView)
let btn = MyButton(type: .Custom)
btn.frame = CGRect(x: 100, y: 200, width: 20, height: 20)
btn.backgroundColor = UIColor.redColor()
btn.setTitle("btn", forState: .Normal)
btn.addTarget(self, action: #selector(UIButtonViewController.tapButton), forControlEvents: .TouchUpInside)
view.addSubview(btn)
}
func tapButton(){
print("button has been pressed!");
}
點擊紫色區(qū)域內容溉躲,同樣可以響應點擊事件祝蝠,可以在console看到打印輸出:button has been pressed!
- 2梆暖、實現(xiàn)傳遞事件到點擊視圖之下的視圖
有的時候對于一個視圖忽略觸摸事件并傳遞給下面的視圖是很重要的凹炸。例如,假設一個透明的視圖覆蓋在應用內所有視圖的最上面缘圈。覆蓋層有子視圖應該相應觸摸事件的一些控件和按鈕靴拱。但是觸摸覆蓋層的其他區(qū)域應該傳遞給覆蓋層下面的視圖国觉。為了完成這個行為酒贬,覆蓋層需要覆蓋hitTest:withEvent:
方法來返回包含觸摸點的子視圖中的一個又憨,然后其他情況返回nil,包括覆蓋層包含觸摸點的情況:
class SHView: UIView {
override func hitTest(point: CGPoint, withEvent event: UIEvent?) -> UIView? {
var hitTestView = super.hitTest(point, withEvent:event)
if hitTestView == self{
hitTestView = nil
}
return hitTestView;
}
}
測試部分代碼:
func testCoverView(){
let btn1 = UIButton(type: .Custom)
btn1.frame = CGRect(x: 80, y: 200, width: 20, height: 20)
btn1.backgroundColor = UIColor.redColor()
btn1.setTitle("btn1", forState: .Normal)
btn1.addTarget(self, action: #selector(OverSuperViewController.tapButton(_:)), forControlEvents: .TouchUpInside)
view.addSubview(btn1)
let btn2 = UIButton(type: .Custom)
btn2.frame = CGRect(x: 120, y: 200, width: 20, height: 20)
btn2.backgroundColor = UIColor.yellowColor()
btn2.setTitle("btn2", forState: .Normal)
btn2.addTarget(self, action: #selector(OverSuperViewController.tapButton(_:)), forControlEvents: .TouchUpInside)
view.addSubview(btn2)
//添加一個覆蓋層
let backgroundView = SHView(frame: CGRect(x: 60, y: 160, width: 100, height: 100))
backgroundView.backgroundColor = UIColor.purpleColor()
backgroundView.alpha = 0.75;
view.addSubview(backgroundView)
}
func tapButton(button:UIButton){
print("button = %@,title = %@",button,button.currentTitle);
}
當點擊覆蓋層的時候锭吨,如果點擊的位置屬于對應的按鈕的區(qū)域蠢莺,將響應對應的觸發(fā)事件,點擊btn1將打印按鈕1的相關信息零如,點擊按鈕2將打印按鈕2的相關信息躏将。頁面效果如下:
- 3锄弱、超出父視圖區(qū)域部分響應事件
首先看一下頁面效果:當前頁面上有3個控件,紫色視圖是紅色視圖的子視圖祸憋,紅色視圖是灰色視圖的子視圖会宪。最上面是一個按鈕,方便我們進行測試:現(xiàn)在我們要實現(xiàn)點擊紅色視圖之外的紫色區(qū)域能夠響應事件蚯窥。
實現(xiàn)代碼:自定義TestView
實現(xiàn)hitTest
方法狈谊,并調用我們對UIView
的擴展方法
class TestView: UIView {
override func hitTest(point: CGPoint, withEvent event: UIEvent?) -> UIView? {
super.hitTest(point, withEvent: event)
return overlapHitTest(point, withEvent: event)
}
}
extension UIView{
func overlapHitTest(point: CGPoint, withEvent event: UIEvent?) -> UIView? {
// We should not send touch events for hidden or transparent views, or views with userInteractionEnabled set to NO;
if !self.userInteractionEnabled || self.hidden || self.alpha == 0 {
return nil
}
// If touch is inside self, self will be considered as potential result.
var hitView: UIView? = self
if !self.pointInside(point, withEvent: event) {
if self.clipsToBounds {
return nil
} else {
hitView = nil
}
}
// Check recursively all subviews for hit. If any, return it.
for subview in self.subviews.reverse() {
let insideSubview = self.convertPoint(point, toView: subview)
if let sview = subview.overlapHitTest(insideSubview, withEvent: event) {
return sview
}
}
// Else return self or nil depending on result from step 2.
return hitView
}
}
測試部分代碼:
func testOverSuperview(){
let view1 = TestView(frame:CGRect(x: 100, y: 100, width: 200, height: 200))
view1.backgroundColor = UIColor.lightGrayColor()
view.addSubview(view1)
let view2 = UIView(frame: CGRect(x: 40, y: 40, width: 100, height: 100))
view2.backgroundColor = UIColor.redColor()
view1.addSubview(view2)
let view3 = UIButton(type: .Custom)
view3.frame = (frame: CGRect(x: 10, y: 10, width: 200, height: 80))
view3.backgroundColor = UIColor.purpleColor()
view3.addTarget(self, action: #selector(ThirdViewController.tapButton), forControlEvents: .TouchUpInside)
view2.addSubview(view3)
}
func tapButton(){
print("button has been pressed!");
}