從 QMUI 看如何設(shè)計(jì)一個(gè)完備的浮層控件

QMUI iOS 是一個(gè)開源的 iOS UI 框架,其中包含很多常用的控件,而浮層控件也是我們?nèi)粘i_發(fā)中使用率很高的控件之一,因此本文借著 QMUIModalPresentationViewController 的源碼來討論在設(shè)計(jì)一個(gè)通用且功能完善的浮層控件時(shí)都需要注意哪些問題。

浮層控件一般用于在 App 里展示一些臨時(shí)性的信息,例如微信里轉(zhuǎn)賬輸入支付密碼的彈窗:


微信支付彈窗

這些浮層都有一些共同特點(diǎn):

  1. 通常都蓋在某個(gè)界面上方,而非自己獨(dú)占一個(gè)界面(也即決定了浮層的顯示不能影響背后界面的顯示,并且浮層的很多特性也要由背后的界面來決定,例如對設(shè)備方向的支持)。
  2. 浮層只占屏幕里的一部分(這在布局上決定了浮層的寬度一般由屏幕寬度減去左右間距得到,而高度通常由內(nèi)容決定而不是由屏幕高度算出)。
  3. 浮層帶遮罩(遮罩可以蓋住狀態(tài)欄,根據(jù)點(diǎn)擊遮罩是否隱藏浮層來分為模態(tài)浮層和非模態(tài)浮層)。
  4. 浮層具備與鍵盤交互的能力(浮層自己管理鍵盤的升起/降下,無需使用者監(jiān)聽相關(guān)事件)。
  5. 浮層的內(nèi)容具備多樣性(也即浮層控件一般都需要自定義內(nèi)容,而無法直接拿來就能用)。
  6. 浮層的打開/關(guān)閉動(dòng)畫具備多樣性(也即浮層控件需要支持方便地自定義動(dòng)畫)。
  7. 通常同一時(shí)間內(nèi)只會(huì)顯示一個(gè)浮層(也即要求有全局管理浮層的能力)。

這么一看,其實(shí)一個(gè)小小的浮層控件背后還是包含了很多設(shè)計(jì)細(xì)節(jié)在內(nèi),接下來我們就對著上述的 7 點(diǎn)分別展開來講。

1. 通常都蓋在某個(gè)界面上方,而非自己獨(dú)占一個(gè)界面

iOS 上一個(gè)界面要顯示出來通常有幾種方式:

  1. UIView 的形式通過 addSubview: 添加到當(dāng)前界面。
  2. UIViewController 的形式通過 pushViewControllerpresentViewController 顯示出來。
  3. UIWindow 的形式直接顯示出來。

從浮層的角度,對于第 1 種,由于 UIView 的層級關(guān)系,如果在一個(gè) UIViewController 里將浮層添加到 self.view 上,則浮層會(huì)被導(dǎo)航欄蓋住,而如果添加到 self.navigationController.view 上,則由于跨層級的管理,self.navigationController 本身無法感知到有一個(gè)自定義 view 存在于界面中,因此浮層容易被其他 view 覆蓋。因此這種適合于一些較為簡單的信息表達(dá),本質(zhì)上并不是“界面切換”,而是“界面內(nèi)容變化”。

對于第 2 種,由于以 UIViewController 的形式存在,因此相比第 1 種多了很多能力,例如能被當(dāng)前界面感知到浮層的顯示/隱藏,也具備管理設(shè)備方向的能力,還能利用 UIViewController 的生命周期來管理浮層的生命周期。而如果使用 pushViewController,會(huì)導(dǎo)致上一個(gè)界面被移除,因此無法實(shí)現(xiàn)“蓋在當(dāng)前界面上方”的效果,因此浮層不能以 pushViewController 的方式來顯示。而 presentViewController 則可通過修改 modalPresentationStyleUIModalPresentationOverCurrentContext 來達(dá)到蓋在當(dāng)前界面之上的效果,但 UIModalPresentationOverCurrentContext 是 iOS 8 新增的類型,對于 iOS 7 及以前的版本則無法實(shí)現(xiàn)。

第 3 種方法相比前兩種更徹底,因?yàn)樵?iOS 里 UIWindow 是整個(gè) View 層級樹的根節(jié)點(diǎn),使用 UIWindow 相當(dāng)于擁有最高的能力,像遮罩蓋住狀態(tài)欄這種效果只有以 UIWindow 的方式才能實(shí)現(xiàn)。但 UIWindow 也有一個(gè)致命的缺陷:它完全獨(dú)立于原有界面的層級關(guān)系,因此如果在浮層里有一些操作需要在原有界面里進(jìn)行界面跳轉(zhuǎn),就不得不隱藏浮層才能看到。

因此從 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. 浮層只占屏幕里的一部分 **

這本質(zhì)上就是指浮層控件的布局,一個(gè)浮層的布局由寬高(size)和原點(diǎn)位置(origin)決定。

如上文所說,寬高一般由屏幕寬度減去左右間距得到,但為了保證在橫屏或者 iPad 下浮層寬度不大得夸張,也會(huì)在間距的基礎(chǔ)上使用最大寬度來限制。所以 QMUIModalPresentationViewController 也提供了對應(yīng)的屬性來控制:

/**
 *  設(shè)置`contentView`布局時(shí)與外容器的間距,默認(rèn)為(20, 20, 20, 20)
 *  @warning 當(dāng)設(shè)置了`layoutBlock`屬性時(shí),此屬性不生效
 */
@property(nonatomic, assign) UIEdgeInsets contentViewMargins UI_APPEARANCE_SELECTOR;

/**
 *  限制`contentView`布局時(shí)的最大寬度,默認(rèn)為iPhone 6豎屏下的屏幕寬度減去`contentViewMargins`在水平方向的值,也即浮層在iPhone 6 Plus或iPad上的寬度以iPhone 6上的寬度為準(zhǔn)。
 *  @warning 當(dāng)設(shè)置了`layoutBlock`屬性時(shí),此屬性不生效
 */
@property(nonatomic, assign) CGFloat maximumContentViewWidth UI_APPEARANCE_SELECTOR;

至于浮層的高度,一般由內(nèi)容決定,設(shè)備屏幕寬高只是一個(gè)輔助參考。所以作為通用的浮層控件,需要有一個(gè)方式能夠讓內(nèi)部的自定義內(nèi)容告訴外部的控件“我的內(nèi)容希望以多大的尺寸來展示”。在 QMUIModalPresentationViewController 里,這個(gè)方式按照自定義內(nèi)容的存在形式分兩種:
1、如果自定義內(nèi)容以 contentViewController 的形式存在,則通過接口 QMUIModalPresentationContentViewControllerProtocol 來告知控件。

@protocol QMUIModalPresentationContentViewControllerProtocol <NSObject>

@optional

/**
 *  當(dāng)浮層以 UIViewController 的形式展示(而非 UIView),并且使用 modalController 提供的默認(rèn)布局時(shí),則可通過這個(gè)方法告訴 modalController 當(dāng)前浮層期望的大小
 *  @param  controller  當(dāng)前的modalController
 *  @param  limitSize   浮層最大的寬高,由當(dāng)前 modalController 的大小及 `contentViewMargins`、`maximumContentViewWidth` 決定
 *  @return 返回浮層在 `limitSize` 限定內(nèi)的大小,如果業(yè)務(wù)自身不需要限制寬度/高度,則為 width/height 返回 `CGFLOAT_MAX` 即可
 */
- (CGSize)preferredContentSizeInModalPresentationViewController:(QMUIModalPresentationViewController *)controller limitSize:(CGSize)limitSize;

@end

2、如果自定義內(nèi)容以 contentView 的形式存在,則會(huì)詢問 contentViewsizeThatFits: 方法來得到期望的大小。

如果默認(rèn)的布局規(guī)則無法滿足你的需求,QMUIModalPresentationViewController 也提供了自定義布局的接口:

/**
 *  管理自定義的浮層布局,將會(huì)在浮層顯示前、控件的容器大小發(fā)生變化時(shí)(例如橫豎屏、來電狀態(tài)欄)被調(diào)用
 *  @arg  containerBounds         浮層所在的父容器的大小,也即`self.view.bounds`
 *  @arg  keyboardHeight          鍵盤在當(dāng)前界面里的高度,若無鍵盤,則為0
 *  @arg  contentViewDefaultFrame 不使用自定義布局的情況下的默認(rèn)布局,會(huì)受`contentViewMargins`、`maximumContentViewWidth`、`contentView sizeThatFits:`的影響
 *
 *  @see contentViewMargins
 *  @see maximumContentViewWidth
 */
@property(nonatomic, copy) void (^layoutBlock)(CGRect containerBounds, CGFloat keyboardHeight, CGRect contentViewDefaultFrame);

layoutBlock 中會(huì)通過參數(shù)告知你當(dāng)前顯示浮層的容器的大小,以及鍵盤的高度(如果有出現(xiàn)鍵盤的話),還有如果使用默認(rèn)布局的情況下浮層的 frame,方便你基于默認(rèn)布局的基礎(chǔ)上微調(diào)。

3. 浮層帶遮罩

這個(gè)沒什么好說的,常見且容易理解。QMUIModalPresentationViewController 提供一個(gè) modal 的屬性允許你切換浮層是否模態(tài),而如果你對遮罩的樣式有自定義的需求,也可將自己的遮罩賦值給 dimmingView 屬性,不過注意你自己的 dimmingView 無需處理點(diǎn)擊事件,QMUIModalPresentationViewController 會(huì)自動(dòng)幫你加上,你只要負(fù)責(zé)好樣式就行了,這一點(diǎn)還是比較省心的,可以保證對外的接口一致。

4. 浮層具備與鍵盤交互的能力

浮層響應(yīng)鍵盤事件時(shí)一般都是為了調(diào)整布局,避免關(guān)鍵內(nèi)容被鍵盤蓋住,所以當(dāng)你在做一個(gè)浮層控件時(shí),鍵盤的監(jiān)聽是必不可少的。但 iOS 里鍵盤的 API 不是很友好,例如當(dāng)你需要獲取鍵盤的高度時(shí)需要做坐標(biāo)系轉(zhuǎn)換、第三方鍵盤可能多次觸發(fā)相同的鍵盤事件并且有時(shí)候鍵盤高度為0、外接硬件鍵盤時(shí)(例如 iPad Pro 官方的保護(hù)殼帶鍵盤)交互也不太一樣,所以這些東西如果每次都交給業(yè)務(wù)處理,業(yè)務(wù)必然也要自己抽取一套代碼,于是 QMUIModalPresentationViewController 里也是簡單整合了與鍵盤交互的能力,主要體現(xiàn)在布局及動(dòng)畫上。

@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 個(gè) block 里,都通過參數(shù)傳遞了當(dāng)前鍵盤的高度,你就可以在 block 體內(nèi)直接使用了。

5. 浮層的內(nèi)容具備多樣性

作為通用的浮層控件,QMUIModalPresentationViewController 單純的只負(fù)責(zé)浮層的展示,至于浮層內(nèi)容均需業(yè)務(wù)自定義。所以 QMUIModalPresentationViewController 提供了兩種形式來展示內(nèi)容:

  • UIView 的形式:contentView 屬性。
  • UIViewController 的形式:contentViewController 屬性。

通常前者適合簡單的場景,后者適合復(fù)雜的場景,業(yè)務(wù)自行選擇。

6. 浮層的打開/關(guān)閉動(dòng)畫具備多樣性

對于浮層的顯隱動(dòng)畫,不同業(yè)務(wù)必定會(huì)有自己的特定需求,所以支持自定義動(dòng)畫是一個(gè)必要的功能。QMUIModalPresentationViewController 通過兩個(gè)屬性來實(shí)現(xiàn)自定義動(dòng)畫:

/**
 *  管理自定義的顯示動(dòng)畫,需要管理的對象包括`contentView`和`dimmingView`,在`showingAnimation`被調(diào)用前,`contentView`已被添加到界面上。若使用了`layoutBlock`,則會(huì)先調(diào)用`layoutBlock`,再調(diào)用`showingAnimation`。在動(dòng)畫結(jié)束后,必須調(diào)用參數(shù)里的`completion` block。
 *  @arg  dimmingView         背景遮罩的View,請自行設(shè)置顯示遮罩的動(dòng)畫
 *  @arg  containerBounds     浮層所在的父容器的大小,也即`self.view.bounds`
 *  @arg  keyboardHeight      鍵盤在當(dāng)前界面里的高度,若無鍵盤,則為0
 *  @arg  contentViewFrame    動(dòng)畫執(zhí)行完后`contentView`的最終frame,若使用了`layoutBlock`,則也即`layoutBlock`計(jì)算完后的frame
 *  @arg  completion          動(dòng)畫結(jié)束后給到modalController的回調(diào),modalController會(huì)在這個(gè)回調(diào)里做一些狀態(tài)設(shè)置,務(wù)必調(diào)用。
 */
@property(nonatomic, copy) void (^showingAnimation)(UIView *dimmingView, CGRect containerBounds, CGFloat keyboardHeight, CGRect contentViewFrame, void(^completion)(BOOL finished));

/**
 *  管理自定義的隱藏動(dòng)畫,需要管理的對象包括`contentView`和`dimmingView`,在動(dòng)畫結(jié)束后,必須調(diào)用參數(shù)里的`completion` block。
 *  @arg  dimmingView         背景遮罩的View,請自行設(shè)置隱藏遮罩的動(dòng)畫
 *  @arg  containerBounds     浮層所在的父容器的大小,也即`self.view.bounds`
 *  @arg  keyboardHeight      鍵盤在當(dāng)前界面里的高度,若無鍵盤,則為0
 *  @arg  completion          動(dòng)畫結(jié)束后給到modalController的回調(diào),modalController會(huì)在這個(gè)回調(diào)里做一些清理工作,務(wù)必調(diào)用
 */
@property(nonatomic, copy) void (^hidingAnimation)(UIView *dimmingView, CGRect containerBounds, CGFloat keyboardHeight, void(^completion)(BOOL finished));

這兩個(gè)屬性淺顯易懂,只要按照注釋的說明來使用即可,沒什么坑點(diǎn)。

7. 通常同一時(shí)間內(nèi)只會(huì)顯示一個(gè)浮層

這是一個(gè)比較容易被忽略的點(diǎn),例如目前的 App 一般都支持在外部通過 url 跳轉(zhuǎn)到 App 內(nèi)的某個(gè)界面,假設(shè)你的 App 正在顯示某個(gè)不重要的浮層,此時(shí)用戶切到其他應(yīng)用,通過其他應(yīng)用里的 url 跳轉(zhuǎn)到你 App 的某個(gè)界面,此時(shí)如果你不先降下浮層,用戶要跳轉(zhuǎn)到的界面就會(huì)一直被之前的浮層蓋住。于是這要求我們需要感知到當(dāng)前 App 里是否有浮層正在顯示,而 QMUIModalPresentationViewController 針對這一點(diǎn)提供了兩個(gè)類方法:

@interface QMUIModalPresentationViewController (Manager)

/**
 *  判斷當(dāng)前App里是否有modalViewController正在顯示(存在modalViewController但不可見的時(shí)候,也視為不存在)
 *  @return 只要存在正在顯示的浮層,則返回YES,否則返回NO
 */
+ (BOOL)isAnyModalPresentationViewControllerVisible;

/**
 *  把所有正在顯示的并且允許被隱藏的modalViewController都隱藏掉
 *  @return 只要遇到一個(gè)正在顯示的并且不能被隱藏的浮層,就會(huì)返回NO,否則都返回YES,表示成功隱藏掉所有可視浮層
 *  @see    shouldHideModalPresentationViewController:
 */
+ (BOOL)hideAllVisibleModalPresentationViewControllerIfCan;
@end

利用這兩個(gè)方法,你就能很好地保護(hù)這種特殊情況。

好了,上文總結(jié)的 7 點(diǎn)已經(jīng)全部講完,可見如果要做一個(gè)好用且全面的浮層,要考慮的細(xì)節(jié)還是很多的。在 QMUI 框架里很多上層控件其實(shí)都是使用 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];
}

// ...

可以看到將浮層功能抽取出來后,每個(gè)業(yè)務(wù)控件只需要管理好自身內(nèi)容即可,無需花精力在“如何把內(nèi)容顯示出來”上,也不用擔(dān)心各種特殊情況下內(nèi)容是否無法正常顯示。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 內(nèi)容抽屜菜單ListViewWebViewSwitchButton按鈕點(diǎn)贊按鈕進(jìn)度條TabLayout圖標(biāo)下拉刷新...
    皇小弟閱讀 47,133評論 22 665
  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 178,725評論 25 709
  • Android UI相關(guān)開源項(xiàng)目庫匯總OpenDigg 抽屜菜單MaterialDrawer ★7337 - 安卓...
    黃海佳閱讀 8,824評論 3 77
  • 我們都是喜歡奔跑的孩子。每天的努力,是為了未來不為難自己,是為了在父母親提起自己時(shí)可以笑容滿面。但人生中有許多條路...
    木禾靜書閱讀 195評論 0 1
  • http://player.kuwo.cn/webmusic/play?f=arphone&t=platform&...
    LONER_Y閱讀 865評論 3 4

友情鏈接更多精彩內(nèi)容