QMUI iOS 是一個開源的 iOS UI 框架,其中包含很多常用的控件,而浮層控件也是我們日常開發中使用率很高的控件之一,因此本文借著 QMUIModalPresentationViewController
的源碼來討論在設計一個通用且功能完善的浮層控件時都需要注意哪些問題。
浮層控件一般用于在 App 里展示一些臨時性的信息,例如微信里轉賬輸入支付密碼的彈窗:
這些浮層都有一些共同特點:
- 通常都蓋在某個界面上方,而非自己獨占一個界面(也即決定了浮層的顯示不能影響背后界面的顯示,并且浮層的很多特性也要由背后的界面來決定,例如對設備方向的支持)。
- 浮層只占屏幕里的一部分(這在布局上決定了浮層的寬度一般由屏幕寬度減去左右間距得到,而高度通常由內容決定而不是由屏幕高度算出)。
- 浮層帶遮罩(遮罩可以蓋住狀態欄,根據點擊遮罩是否隱藏浮層來分為模態浮層和非模態浮層)。
- 浮層具備與鍵盤交互的能力(浮層自己管理鍵盤的升起/降下,無需使用者監聽相關事件)。
- 浮層的內容具備多樣性(也即浮層控件一般都需要自定義內容,而無法直接拿來就能用)。
- 浮層的打開/關閉動畫具備多樣性(也即浮層控件需要支持方便地自定義動畫)。
- 通常同一時間內只會顯示一個浮層(也即要求有全局管理浮層的能力)。
這么一看,其實一個小小的浮層控件背后還是包含了很多設計細節在內,接下來我們就對著上述的 7 點分別展開來講。
1. 通常都蓋在某個界面上方,而非自己獨占一個界面
iOS 上一個界面要顯示出來通常有幾種方式:
- 以
UIView
的形式通過addSubview:
添加到當前界面。 - 以
UIViewController
的形式通過pushViewController
或presentViewController
顯示出來。 - 以
UIWindow
的形式直接顯示出來。
從浮層的角度,對于第 1 種,由于 UIView
的層級關系,如果在一個 UIViewController
里將浮層添加到 self.view
上,則浮層會被導航欄蓋住,而如果添加到 self.navigationController.view
上,則由于跨層級的管理,self.navigationController
本身無法感知到有一個自定義 view 存在于界面中,因此浮層容易被其他 view 覆蓋。因此這種適合于一些較為簡單的信息表達,本質上并不是“界面切換”,而是“界面內容變化”。
對于第 2 種,由于以 UIViewController
的形式存在,因此相比第 1 種多了很多能力,例如能被當前界面感知到浮層的顯示/隱藏,也具備管理設備方向的能力,還能利用 UIViewController
的生命周期來管理浮層的生命周期。而如果使用 pushViewController
,會導致上一個界面被移除,因此無法實現“蓋在當前界面上方”的效果,因此浮層不能以 pushViewController
的方式來顯示。而 presentViewController
則可通過修改 modalPresentationStyle
為 UIModalPresentationOverCurrentContext
來達到蓋在當前界面之上的效果,但 UIModalPresentationOverCurrentContext
是 iOS 8 新增的類型,對于 iOS 7 及以前的版本則無法實現。
第 3 種方法相比前兩種更徹底,因為在 iOS 里 UIWindow
是整個 View 層級樹的根節點,使用 UIWindow
相當于擁有最高的能力,像遮罩蓋住狀態欄這種效果只有以 UIWindow
的方式才能實現。但 UIWindow
也有一個致命的缺陷:它完全獨立于原有界面的層級關系,因此如果在浮層里有一些操作需要在原有界面里進行界面跳轉,就不得不隱藏浮層才能看到。
因此從 QMUIModalPresentationViewController.h
里可以看到,QMUIModalPresentationViewController
針對以上 3 種場景也提供了 3 種方式來顯示浮層:
// 1、以 addSubview: 的方式使用
self.modalPresentationViewController.view.frame = CGRectMake(50, 50, 100, 100);
[self.view addSubview:self.modalPresentationViewController.view];
// 2、以 present 的方式使用
[self presentViewController:modalPresentationViewController animated:NO completion:nil];
// 3、以 UIWindow 的方式使用(官方推薦)
[modalPresentationViewController showWithAnimated:YES completion:nil];
** 2. 浮層只占屏幕里的一部分 **
這本質上就是指浮層控件的布局,一個浮層的布局由寬高(size
)和原點位置(origin
)決定。
如上文所說,寬高一般由屏幕寬度減去左右間距得到,但為了保證在橫屏或者 iPad 下浮層寬度不大得夸張,也會在間距的基礎上使用最大寬度來限制。所以 QMUIModalPresentationViewController
也提供了對應的屬性來控制:
/**
* 設置`contentView`布局時與外容器的間距,默認為(20, 20, 20, 20)
* @warning 當設置了`layoutBlock`屬性時,此屬性不生效
*/
@property(nonatomic, assign) UIEdgeInsets contentViewMargins UI_APPEARANCE_SELECTOR;
/**
* 限制`contentView`布局時的最大寬度,默認為iPhone 6豎屏下的屏幕寬度減去`contentViewMargins`在水平方向的值,也即浮層在iPhone 6 Plus或iPad上的寬度以iPhone 6上的寬度為準。
* @warning 當設置了`layoutBlock`屬性時,此屬性不生效
*/
@property(nonatomic, assign) CGFloat maximumContentViewWidth UI_APPEARANCE_SELECTOR;
至于浮層的高度,一般由內容決定,設備屏幕寬高只是一個輔助參考。所以作為通用的浮層控件,需要有一個方式能夠讓內部的自定義內容告訴外部的控件“我的內容希望以多大的尺寸來展示”。在 QMUIModalPresentationViewController
里,這個方式按照自定義內容的存在形式分兩種:
1、如果自定義內容以 contentViewController
的形式存在,則通過接口 QMUIModalPresentationContentViewControllerProtocol
來告知控件。
@protocol QMUIModalPresentationContentViewControllerProtocol <NSObject>
@optional
/**
* 當浮層以 UIViewController 的形式展示(而非 UIView),并且使用 modalController 提供的默認布局時,則可通過這個方法告訴 modalController 當前浮層期望的大小
* @param controller 當前的modalController
* @param limitSize 浮層最大的寬高,由當前 modalController 的大小及 `contentViewMargins`、`maximumContentViewWidth` 決定
* @return 返回浮層在 `limitSize` 限定內的大小,如果業務自身不需要限制寬度/高度,則為 width/height 返回 `CGFLOAT_MAX` 即可
*/
- (CGSize)preferredContentSizeInModalPresentationViewController:(QMUIModalPresentationViewController *)controller limitSize:(CGSize)limitSize;
@end
2、如果自定義內容以 contentView
的形式存在,則會詢問 contentView
的 sizeThatFits:
方法來得到期望的大小。
如果默認的布局規則無法滿足你的需求,QMUIModalPresentationViewController
也提供了自定義布局的接口:
/**
* 管理自定義的浮層布局,將會在浮層顯示前、控件的容器大小發生變化時(例如橫豎屏、來電狀態欄)被調用
* @arg containerBounds 浮層所在的父容器的大小,也即`self.view.bounds`
* @arg keyboardHeight 鍵盤在當前界面里的高度,若無鍵盤,則為0
* @arg contentViewDefaultFrame 不使用自定義布局的情況下的默認布局,會受`contentViewMargins`、`maximumContentViewWidth`、`contentView sizeThatFits:`的影響
*
* @see contentViewMargins
* @see maximumContentViewWidth
*/
@property(nonatomic, copy) void (^layoutBlock)(CGRect containerBounds, CGFloat keyboardHeight, CGRect contentViewDefaultFrame);
在 layoutBlock
中會通過參數告知你當前顯示浮層的容器的大小,以及鍵盤的高度(如果有出現鍵盤的話),還有如果使用默認布局的情況下浮層的 frame
,方便你基于默認布局的基礎上微調。
3. 浮層帶遮罩
這個沒什么好說的,常見且容易理解。QMUIModalPresentationViewController
提供一個 modal
的屬性允許你切換浮層是否模態,而如果你對遮罩的樣式有自定義的需求,也可將自己的遮罩賦值給 dimmingView
屬性,不過注意你自己的 dimmingView
無需處理點擊事件,QMUIModalPresentationViewController
會自動幫你加上,你只要負責好樣式就行了,這一點還是比較省心的,可以保證對外的接口一致。
4. 浮層具備與鍵盤交互的能力
浮層響應鍵盤事件時一般都是為了調整布局,避免關鍵內容被鍵盤蓋住,所以當你在做一個浮層控件時,鍵盤的監聽是必不可少的。但 iOS 里鍵盤的 API 不是很友好,例如當你需要獲取鍵盤的高度時需要做坐標系轉換、第三方鍵盤可能多次觸發相同的鍵盤事件并且有時候鍵盤高度為0、外接硬件鍵盤時(例如 iPad Pro 官方的保護殼帶鍵盤)交互也不太一樣,所以這些東西如果每次都交給業務處理,業務必然也要自己抽取一套代碼,于是 QMUIModalPresentationViewController
里也是簡單整合了與鍵盤交互的能力,主要體現在布局及動畫上。
@property(nonatomic, copy) void (^layoutBlock)(CGRect containerBounds, CGFloat keyboardHeight, CGRect contentViewDefaultFrame);
@property(nonatomic, copy) void (^showingAnimation)(UIView *dimmingView, CGRect containerBounds, CGFloat keyboardHeight, CGRect contentViewFrame, void(^completion)(BOOL finished));
@property(nonatomic, copy) void (^hidingAnimation)(UIView *dimmingView, CGRect containerBounds, CGFloat keyboardHeight, void(^completion)(BOOL finished));
在以上 3 個 block
里,都通過參數傳遞了當前鍵盤的高度,你就可以在 block
體內直接使用了。
5. 浮層的內容具備多樣性
作為通用的浮層控件,QMUIModalPresentationViewController
單純的只負責浮層的展示,至于浮層內容均需業務自定義。所以 QMUIModalPresentationViewController
提供了兩種形式來展示內容:
- 以
UIView
的形式:contentView
屬性。 - 以
UIViewController
的形式:contentViewController
屬性。
通常前者適合簡單的場景,后者適合復雜的場景,業務自行選擇。
6. 浮層的打開/關閉動畫具備多樣性
對于浮層的顯隱動畫,不同業務必定會有自己的特定需求,所以支持自定義動畫是一個必要的功能。QMUIModalPresentationViewController
通過兩個屬性來實現自定義動畫:
/**
* 管理自定義的顯示動畫,需要管理的對象包括`contentView`和`dimmingView`,在`showingAnimation`被調用前,`contentView`已被添加到界面上。若使用了`layoutBlock`,則會先調用`layoutBlock`,再調用`showingAnimation`。在動畫結束后,必須調用參數里的`completion` block。
* @arg dimmingView 背景遮罩的View,請自行設置顯示遮罩的動畫
* @arg containerBounds 浮層所在的父容器的大小,也即`self.view.bounds`
* @arg keyboardHeight 鍵盤在當前界面里的高度,若無鍵盤,則為0
* @arg contentViewFrame 動畫執行完后`contentView`的最終frame,若使用了`layoutBlock`,則也即`layoutBlock`計算完后的frame
* @arg completion 動畫結束后給到modalController的回調,modalController會在這個回調里做一些狀態設置,務必調用。
*/
@property(nonatomic, copy) void (^showingAnimation)(UIView *dimmingView, CGRect containerBounds, CGFloat keyboardHeight, CGRect contentViewFrame, void(^completion)(BOOL finished));
/**
* 管理自定義的隱藏動畫,需要管理的對象包括`contentView`和`dimmingView`,在動畫結束后,必須調用參數里的`completion` block。
* @arg dimmingView 背景遮罩的View,請自行設置隱藏遮罩的動畫
* @arg containerBounds 浮層所在的父容器的大小,也即`self.view.bounds`
* @arg keyboardHeight 鍵盤在當前界面里的高度,若無鍵盤,則為0
* @arg completion 動畫結束后給到modalController的回調,modalController會在這個回調里做一些清理工作,務必調用
*/
@property(nonatomic, copy) void (^hidingAnimation)(UIView *dimmingView, CGRect containerBounds, CGFloat keyboardHeight, void(^completion)(BOOL finished));
這兩個屬性淺顯易懂,只要按照注釋的說明來使用即可,沒什么坑點。
7. 通常同一時間內只會顯示一個浮層
這是一個比較容易被忽略的點,例如目前的 App 一般都支持在外部通過 url 跳轉到 App 內的某個界面,假設你的 App 正在顯示某個不重要的浮層,此時用戶切到其他應用,通過其他應用里的 url 跳轉到你 App 的某個界面,此時如果你不先降下浮層,用戶要跳轉到的界面就會一直被之前的浮層蓋住。于是這要求我們需要感知到當前 App 里是否有浮層正在顯示,而 QMUIModalPresentationViewController
針對這一點提供了兩個類方法:
@interface QMUIModalPresentationViewController (Manager)
/**
* 判斷當前App里是否有modalViewController正在顯示(存在modalViewController但不可見的時候,也視為不存在)
* @return 只要存在正在顯示的浮層,則返回YES,否則返回NO
*/
+ (BOOL)isAnyModalPresentationViewControllerVisible;
/**
* 把所有正在顯示的并且允許被隱藏的modalViewController都隱藏掉
* @return 只要遇到一個正在顯示的并且不能被隱藏的浮層,就會返回NO,否則都返回YES,表示成功隱藏掉所有可視浮層
* @see shouldHideModalPresentationViewController:
*/
+ (BOOL)hideAllVisibleModalPresentationViewControllerIfCan;
@end
利用這兩個方法,你就能很好地保護這種特殊情況。
好了,上文總結的 7 點已經全部講完,可見如果要做一個好用且全面的浮層,要考慮的細節還是很多的。在 QMUI 框架里很多上層控件其實都是使用 QMUIModalPresentationViewController
來展示的,例如以下的代碼片段取自 QMUIDialogViewController
。
// ...
- (void)showWithAnimated:(BOOL)animated completion:(void (^)(BOOL))completion {
QMUIModalPresentationViewController *modalPresentationViewController = [[QMUIModalPresentationViewController alloc] init];
modalPresentationViewController.contentViewMargins = self.contentViewMargins;
modalPresentationViewController.contentViewController = self;
modalPresentationViewController.modal = YES;
[modalPresentationViewController showWithAnimated:YES completion:completion];
}
// ...
可以看到將浮層功能抽取出來后,每個業務控件只需要管理好自身內容即可,無需花精力在“如何把內容顯示出來”上,也不用擔心各種特殊情況下內容是否無法正常顯示。