iOS: 自定義引導(dǎo)氣泡的 N 種實(shí)現(xiàn)方法

關(guān)鍵詞:iOS、引導(dǎo)頁(yè)、自定義View、氣泡、AutoLayout、自動(dòng)布局、OC、Objective-C、CALayer、CATextLayer、intrinsicContentSize

在上一篇文章 iOS: 引導(dǎo)頁(yè) UIScrollView 自動(dòng)布局(AutoLayout)詳解
中介紹了一個(gè)開(kāi)屏引導(dǎo)頁(yè)的實(shí)現(xiàn),還有一種引導(dǎo)也很常用,就是浮動(dòng)氣泡引導(dǎo)。說(shuō)白了就是在進(jìn)入應(yīng)用界面后為了防止用戶一臉懵逼,給關(guān)鍵的按鈕啊文字啊,高亮一下,加上一堆小氣泡,氣泡里再加點(diǎn)文字介紹。這樣就能對(duì)界面起到一個(gè)說(shuō)明的作用,也能讓用戶順著你的思路使用。

氣泡引導(dǎo)的關(guān)鍵技術(shù)是自定義氣泡 View,氣泡起到指示說(shuō)明和承載消息的作用,是由一張圖片和一段文字組成的,實(shí)現(xiàn)氣泡的方法有好幾種:

  • UIView 組合:直接組合 UILabel 與 UIImageView
  • CALayer: 使用 CATextLayer 結(jié)合 CALayer 寄宿圖
  • 單 UILabel:?jiǎn)为?dú)使用 UILabel 并使用 CALayer 寄宿圖

其中最簡(jiǎn)單最靈活的實(shí)現(xiàn)方式就是第一種組合法,本文以引導(dǎo)氣泡功能為例,總結(jié)自定義氣泡 View (BubbleView)的組合方式的實(shí)現(xiàn)方法,并在后面簡(jiǎn)單介紹和分析一下本人嘗試后兩種方法遇到的坑??????。

需求

有三個(gè)需要引導(dǎo)的按鈕,每一個(gè)按鈕需要顯示一個(gè)氣泡對(duì)功能進(jìn)行說(shuō)明,一次只顯示一個(gè)氣泡,每按一次屏幕顯示下一個(gè)氣泡。如圖:


氣泡引導(dǎo)

基礎(chǔ)知識(shí):氣泡圖片如何合適地拉伸

合適地拉伸

氣泡的大小需要適應(yīng)文字內(nèi)容,比如只有幾個(gè)字的時(shí)候氣泡要緊緊包裹文字不能過(guò)大:


少量文字

文字多的時(shí)候就要顯示成兩行或更多:


大量文字

直接用一張圖行不行?

直接用圖會(huì)產(chǎn)生整張圖片拉伸的效果:


被拉伸的氣泡

拉伸圖片的方法

① 使用 UIImage 提供的拉伸方法:

- (UIImage *)resizableImageWithCapInsets:(UIEdgeInsets)capInsets;

該方法是直接操作 UIImage 的,根據(jù)原始的 UIImage 生成一個(gè)拉伸過(guò)的 UIImage。

參數(shù) UIEdgeInsets capInsets 表示圖片四個(gè)方向上的固定區(qū)域大小,中間區(qū)域就是可以拉伸的范圍:

指定拉伸范圍

可拉伸區(qū)域的大小會(huì)影響繪制的效率。官方文檔中指出,可拉伸區(qū)域只有 1x1 的像素大小是效率最高的。文檔原文

使用方法 resizableImageWithCapInsets 設(shè)置的時(shí)候單位是 point,我們知道一個(gè) point 在不同的設(shè)備上表示的像素可能不一樣,不太方便設(shè)置成 1x1 像素。

② 在 Xcode IB 中對(duì)圖片進(jìn)行設(shè)置:


使用 Xcode 設(shè)置

還可以使用圖形化編輯界面,這個(gè)功能藏的好深……


Show Slicing

Slicing 在屬性窗口的最下端,其中填寫的數(shù)字的單位是像素而不是 point。注意對(duì)每個(gè)尺寸的圖需要進(jìn)行單獨(dú)設(shè)置,也就是說(shuō)有幾個(gè)圖就要設(shè)置幾次。設(shè)置的時(shí)候麻煩一些,但使用的時(shí)候方便,可以直接在 Xcode IB 中設(shè)置給需要 UIImage 的屬性,也可以直接調(diào)用 + (NSImage *)imageNamed:(NSImageName)name; 獲取到有拉伸效果的 UIImage,不必再調(diào)用 resizableImageWithCapInsets。這個(gè)方法設(shè)置 1x1 像素拉伸區(qū)域比較方便。

③ 還有一種更麻煩的方法:使用 CALayer 寄宿圖,通過(guò) contentsCenter 屬性來(lái)設(shè)置可拉伸區(qū)域,這里就不展開(kāi)了,可以參考這里:iOS核心動(dòng)畫高級(jí)技巧 - contents 屬性。

組合 UILabel 與 UIImageView 實(shí)現(xiàn) BubbleView

自適應(yīng)的 UILabel 與 BubbleView

UILabel 的一個(gè)重要功能是自適應(yīng)大小,在自動(dòng)布局中分為幾種情況:

  • 不設(shè)置寬度和高度,此時(shí) UILabel 會(huì)將文字顯示為一行,并且有多寬顯示多寬。
  • 設(shè)置寬度約束不設(shè)置高度約束,此時(shí) UILabel 會(huì)滿足寬度約束,如果文字太多,寬度超出了顯示范圍會(huì)根據(jù) numberOfLines 屬性計(jì)算高度,裁剪掉超出的部分,如果沒(méi)超出或者 numberOfLines = 0 則自動(dòng)調(diào)整高度顯示所有文字內(nèi)容。
  • 同時(shí)設(shè)置了寬度和高度約束,此時(shí) UILabel 大小固定,內(nèi)容無(wú)法影響大小,如果顯示不下內(nèi)容會(huì)截?cái)唷?/li>

對(duì)氣泡來(lái)說(shuō),指定寬度最大值,不限制高度是比較常見(jiàn)的需求,但最好是什么情況都能支持。

最重要的文字自適應(yīng)已經(jīng)由 UILabel 解決了,只要讓 BubbleView 的長(zhǎng)寬約束依賴于 UILabel 就能使 BubbleView 獲得與 UILabel 同樣的自適應(yīng)能力。

下面列出 BubbleView 的約束:

BubbleView 內(nèi)部約束

其實(shí)就是兩批約束:

  • BubbleView 的四個(gè)邊對(duì)齊 UIImageView 的四個(gè)邊,表示 BubbleView 要與圖片大小相同。
  • UIImageView 的四個(gè)邊對(duì)齊 UILabel 的四個(gè)邊,表示圖片大小要與文字相同,這幾個(gè)約束后面還需要通過(guò)代碼來(lái)設(shè)置 UILabel 在整個(gè) BubbleView 中的 padding。

圖中被拉伸的氣泡是 Xcode IB 的顯示問(wèn)題,即使正確設(shè)置了 Slicing 也不能正確地顯示,不過(guò)不耽誤運(yùn)行效果。

再看一下如何設(shè)置 BubbleView 的外部約束:


BubbleView 外部約束

BubbleView 的位置沒(méi)有什么影響,可以隨意設(shè)置,關(guān)鍵在于寬度和高度的約束,圖中所示使用了 width <= 253 來(lái)指定寬度最大值。但由于 Xcode IB 不知道 BubbleView 能計(jì)算自己的大小因此會(huì)有紅色的錯(cuò)誤提示。

Content Hugging Priority 與 Content Compression Resistance Priority

這兩個(gè)特長(zhǎng)的東西是個(gè)啥玩意,別著急請(qǐng)接著上文繼續(xù)看。

自定義 View 想要告知 Xcode IB 自己能計(jì)算大小,并在 IB 中實(shí)時(shí)刷新效果,需要在 interface 聲明前加上 IB_DESIGNABLE。一旦自定義 View 有修改,然后回到 xib 文件時(shí)就會(huì)觸發(fā) build 并且刷新 IB 界面,在開(kāi)發(fā)過(guò)程中會(huì)比較慢和卡,我的 Air 能卡成??,而且 Xcode IB 中總有一些小問(wèn)題,不建議在開(kāi)發(fā)自定義 View 的過(guò)程中開(kāi)啟這個(gè)功能。

雖然有紅色的錯(cuò)誤提示,但是不管它最終運(yùn)行也是正確的,只是看起來(lái)不爽……不行,我受不了這個(gè)委屈,得研究研究怎么解決,這一研究就發(fā)現(xiàn)了 Content Hugging Priority 與 Content Compression Resistance Priority 的神奇奧秘。

設(shè)置優(yōu)先級(jí)較低的定值寬高 width = 253 @100height = 36 @100,對(duì) Xcode IB 來(lái)說(shuō)就補(bǔ)上了缺失的寬和高不會(huì)再報(bào)錯(cuò),而在運(yùn)行時(shí)會(huì)有 BubbleView 內(nèi)部 UILabel 傳遞過(guò)來(lái)的寬和高,這個(gè)寬和高的約束優(yōu)先級(jí)就比較有趣了,是內(nèi)部的 UILabel 的 Content Hugging Priority 和 Content Compression Resistance Priority,他們倆的默認(rèn)值是 250 和 750,肯定比 100 要優(yōu)先,因此會(huì)忽略設(shè)置的這兩個(gè) width = 253 @100height = 36 @100。達(dá)到了敷衍 Xcode 又能正確運(yùn)行的目的。

Content Hugging(CH)與 Content Compression Resistance(CCR)是 UIView 的屬性,用來(lái)表示當(dāng)一個(gè) UIView 自己決定自己的大小的時(shí)候(比如 UILabel),這個(gè)自定義大小在自動(dòng)布局體系內(nèi)的優(yōu)先級(jí)。

  • Content Hugging 表示不被拉伸的優(yōu)先級(jí)
  • Content Compression Resistance 表示不被壓縮的優(yōu)先級(jí)

這兩個(gè)值都有兩個(gè)維度:水平方向和豎直方向。

如果通過(guò)約束計(jì)算出來(lái)的寬度或高度與自定義的大小有沖突,這時(shí)候 CH 和 CCR 就派上用場(chǎng)了。定義:

  • 約束計(jì)算出來(lái)的寬高為 w、h
  • 自定義寬高為 iw、ih
  • 最終結(jié)果寬高為 width、height
  • 約束為寬度 X、高度 Y
  • CH 寬和高分別為 CH-W、CH-H
  • CCR 寬和高分別為 CCR-W、CCR-H
  • 優(yōu)先級(jí)為 .priority。

偽代碼如下:

if (w > iw) width = X.priority > CH-W.priority ? w : iw;
if (w < iw) width = X.priority > CCR-W.priority ? w : iw;
if (h > ih) height = Y.priority > CH-H.priority ? h : ih;
if (h < ih) height = Y.priority > CCR-H.priority ? h : ih;

通常都用兩個(gè) UILabel 來(lái)實(shí)驗(yàn) CH 和 CCR 的效果,這也是關(guān)于 CH 與 CCR 最常見(jiàn)的 case,具體可以參考這篇文章,iOS開(kāi)發(fā)之AutoLayout中的Content Hugging Priority和 Content Compression Resistance Priority解析

BubbleView 的接口

做為一個(gè)自定義 View,應(yīng)該提供給使用者怎樣的接口呢?BubbleView 是不提供圖片資源的,因此需要外部指定圖片,同時(shí)跟圖片有關(guān)系的還有一個(gè)可選的 UIEdgeInsets 表示圖片拉伸信息;另一個(gè)顯而易見(jiàn)的屬性是文字,文字同樣也有個(gè) UIEdgeInsets,表示文字在整個(gè) BubbleView 中的 padding;另外還有文字樣式的設(shè)置。

@interface BubbleView : UIView
@property (nonatomic, strong) UIImage *image;
@property (nonatomic, assign) UIEdgeInsets imageCapInsets;
@property (nonatomic, copy) NSString* text;
@property (nonatomic, assign) UIEdgeInsets textEdgeInsets;
@property (nonatomic, strong) UIFont *font;
@property (nonatomic, strong) UIColor *textColor;
@end

使用 CALayer 圖層組合實(shí)現(xiàn) BubbleView

CALayer 是特別強(qiáng)大的,它是 UIKit 圖形部分的基礎(chǔ),平常最常用的應(yīng)該就是設(shè)置圓角了吧:view.layer.cornerRadius。它還有許多強(qiáng)大的高級(jí)功能,例如上文也提到過(guò) contentsCenter 可以用來(lái)拉伸氣泡圖。實(shí)際上用 CALayer 實(shí)現(xiàn)的氣泡就用到了這個(gè)屬性。下面來(lái)簡(jiǎn)單分析一下。

同樣是一張圖片和一段文字,圖片好說(shuō),用寄宿圖,伸縮也沒(méi)問(wèn)題。文字就要用到 CATextLayer 了,這個(gè) CATextLayer 簡(jiǎn)直就是 UILabel 啊,可以設(shè)置字體、顏色、換行行為等等,貌似什么功能都有的。

但 CATextLayer 這貨有一個(gè)最大的問(wèn)題是無(wú)法自適應(yīng)文字來(lái)調(diào)整自己的大小。CATextLayer 并不是 AutoLayout 體系中的,CATextLayer 的 frame 屬性需要明確的手動(dòng)設(shè)置,而不是自己自動(dòng)設(shè)置。

那么怎么計(jì)算一段文字應(yīng)該占多大的矩形空間呢?比較原始的方法可以用 CoreText。也可以用比較簡(jiǎn)單的 NSAttributedString 的 boundingRectWithSize:options:context: 方法。由于 CATextLayer 直接支持設(shè)置 NSAttributedString 文字,而且這兩種方法效果相同,因此就直接使用第二種方式計(jì)算。

雖然理論上很完美,但這個(gè)計(jì)算還是有點(diǎn)問(wèn)題,因?yàn)?CATextLayer 這貨雖然支持 NSAttributedString,但并不是所有的樣式都支持,比如行間距就無(wú)法設(shè)置。無(wú)法設(shè)置就沒(méi)辦法控制精確的樣式,而且你也無(wú)法得知 CATextLayer 的默認(rèn)樣式的精確值,因此無(wú)法通過(guò) boundingRectWithSize:options:context: 方法來(lái)計(jì)算出精確的應(yīng)有尺寸。

根據(jù)經(jīng)驗(yàn),行間距大概是 1,但經(jīng)過(guò)本人的實(shí)驗(yàn),并不精確,可能還要小一點(diǎn)。有些實(shí)驗(yàn)計(jì)算出來(lái)后大小就是不準(zhǔn)確,實(shí)際繪制的文字區(qū)域要比計(jì)算出來(lái)的矩形區(qū)域要大。

既然沒(méi)法辦精確控制和計(jì)算,而且也導(dǎo)致最終氣泡效果有些問(wèn)題,因此這個(gè)方法沒(méi)有應(yīng)用在實(shí)際項(xiàng)目中。

這個(gè)方法本質(zhì)上相當(dāng)于實(shí)現(xiàn)一個(gè)帶邊距帶底圖的 UILabel,而且還要能自動(dòng)計(jì)算大小,上問(wèn)提到了計(jì)算文字矩形的方法和問(wèn)題,但還有另一個(gè)問(wèn)題待解決就是如何與 AutoLayout 系統(tǒng)溝通并最終決定大小。

首先要看 intrinsicContentSize 這個(gè)屬性,這是一個(gè)只讀屬性:

@property(nonatomic, readonly) CGSize intrinsicContentSize;

其實(shí)就是一個(gè)返回 CGSize 的無(wú)參數(shù)方法,當(dāng)自定義 View 需要自己計(jì)算大小的時(shí)候,要重寫這個(gè)方法,默認(rèn)實(shí)現(xiàn)是返回 CGSize(UIViewNoIntrinsicMetric, UIViewNoIntrinsicMetric)UIViewNoIntrinsicMetric 表示沒(méi)有自定義大小。簡(jiǎn)單地說(shuō),這個(gè)方法是用來(lái)通知 AutoLayout 系統(tǒng)自己「本來(lái)應(yīng)該有多大」,注意「本來(lái)應(yīng)該有多大」的判定時(shí)只能通過(guò)自己的屬性來(lái)判斷,而無(wú)法得知 AutoLayout 給你留了多大地方。

就像是父母對(duì)孩子說(shuō),你要多少壓歲錢,雖然父母心中有數(shù),但不告訴孩子啊,孩子只知道自己要一個(gè)游戲機(jī),于是說(shuō)那就 3000 吧,結(jié)果父母一翻白眼,給你 300 買個(gè)小霸王吧。

所以在父母只給 300 的前提下如何玩到游戲……那就只好再討價(jià)還價(jià)了。

- (void)layoutSublayersOfLayer:(CALayer *)layer; 執(zhí)行時(shí)可以通過(guò) layer.bounds.size 得知 AutoLayout 到底給你準(zhǔn)備了多大的空間,這時(shí)可以記錄下來(lái)備用。通過(guò)調(diào)用 invalidateIntrinsicContentSize 這個(gè)方法通知 AutoLayout 系統(tǒng)重新計(jì)算大小,就會(huì)重新調(diào)用 intrinsicContentSize 方法,這時(shí)可以根據(jù)之前記錄的大小來(lái)重新計(jì)算,比如第一次 intrinsicContentSize 返回了 CGSize(3000, 40) 但在 layoutSublayersOfLayer 內(nèi)發(fā)現(xiàn)給你分配的大小是 CGSize(300, 40),這個(gè)時(shí)候按照寬度 200 重新計(jì)算文字矩形返回 CGSize(300, 400),這樣就計(jì)算出了在規(guī)定了最大寬度時(shí)的文字覺(jué)醒。

孩子說(shuō) 300 買不了游戲機(jī),每天多玩兩個(gè)小時(shí)平板電腦吧,結(jié)果父母一翻白眼,多玩半個(gè)小時(shí)。

所以討價(jià)還價(jià)一次還是不夠,最終大小還得再來(lái)一次,看看父母在高度上的容忍底線在哪里……這是一個(gè)非常復(fù)雜的過(guò)程就不繼續(xù)分析了,有興趣的可以重寫一下 UILabel 的 intrinsicContentSize 方法打個(gè) log 看看會(huì)被調(diào)用多少次,看到 UILabel 也要調(diào)用 n 次才行,就平衡了。

根本原因還是單方向的溝通造成的,intrinsicContentSize 方法本身并不知道對(duì)自己的大小限制是怎樣的,必須靠來(lái)來(lái)回回的問(wèn)答方式迂回地解決這個(gè)問(wèn)題。熟悉安卓的朋友可以對(duì)比一下安卓的做法,安卓的 onMeasure 方法傳入的參數(shù)就是父控件對(duì)子控件的要求,子控件只要在重寫的 onMeasure 方法中根據(jù)父控件的要求設(shè)置自己的大小就可以了,一次搞定不用反復(fù)溝通。

關(guān)于 intrinsicContentSize 的具體用法可以參考這篇文章:只有20%的iOS程序員能看懂:詳解intrinsicContentSize 及 約束優(yōu)先級(jí)/content Hugging/content Compression Resistance

單個(gè) UILabel 的實(shí)現(xiàn)

這是個(gè)有趣的方式,它的問(wèn)題更多,但在某些情況下還是正確的,而且它是最簡(jiǎn)單的一種方案。

還是通過(guò) CALayer,給 UILabel 的根 CALayer 設(shè)置寄宿圖表示氣泡圖片。這個(gè)思路貌似可以,經(jīng)過(guò)一次試驗(yàn)也是可以的。但問(wèn)題在于顯示中文時(shí)能正確將氣泡鋪在文字底部,而顯示英文時(shí)文字沒(méi)了。

真是一個(gè)神奇的效果,經(jīng)過(guò)調(diào)試分析發(fā)現(xiàn),顯示中文時(shí)用的是額外的一個(gè) CALayer,這時(shí) UILabel 的根 CALayer 就會(huì)顯示在額外的 CALayer 之下,達(dá)成了氣泡成就;顯示英文時(shí)就直接繪制在根 CALayer 上了,這個(gè)時(shí)候再設(shè)置寄宿圖,就會(huì)將文字覆蓋掉……

因此最終也沒(méi)有采用這個(gè)方法。

結(jié)論

研究過(guò)若干種方法,回頭看看組合方式的實(shí)現(xiàn),簡(jiǎn)單、無(wú)坑、可靠,還是用最簡(jiǎn)單的組合方式吧。

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

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

  • 1 CALayer IOS SDK詳解之CALayer(一) http://doc.okbase.net/Hell...
    Kevin_Junbaozi閱讀 5,346評(píng)論 3 23
  • 發(fā)現(xiàn) 關(guān)注 消息 iOS 第三方庫(kù)、插件、知名博客總結(jié) 作者大灰狼的小綿羊哥哥關(guān)注 2017.06.26 09:4...
    肇東周閱讀 15,628評(píng)論 4 61
  • 一、看書之前 今天和大家分享是一本有關(guān)于模仿的故事:Caps for sale。這是一本享譽(yù)75年的美國(guó)經(jīng)典童書,...
    疊疊不休閱讀 635評(píng)論 0 10
  • 可能對(duì)于異鄉(xiāng)人而言,每個(gè)陌生的城市都是迷宮。 還記得今年2月份在天津藝考等公交的時(shí)候,一個(gè)裹著厚厚黃色外套的小孩子...
    cdacd61a5d3b閱讀 264評(píng)論 0 0
  • 生活中,我們身邊的親人愛(ài)人家人,我們有時(shí)候常說(shuō)拿你沒(méi)辦法,而這句話的深在含義是一個(gè)人你明知道他有這樣那樣的不好的地...
    海豚翼閱讀 641評(píng)論 0 2

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