UIAppearance漫談

weixin_34236497發表於2018-05-04

前言

在一些app中會涉及到更改外觀設定的功能,最普遍的就是夜間模式和白天模式的切換,而對於外觀的更改必定是一個全域性的東西。在iOS5以前,想要實現這樣的效果是比較困難的,而再iOS5的時候Apple推出了UIAppearance,使得外觀的自定義更加容易實現。

通常某個app都有自己的主題外觀,而在自定義導航欄的時候或許是使用到如下面的程式碼:

[UINavigationBar appearance].barTintColor = [UIColor  redColor];

或者

[[UIBarButtonItem appearance]  setTintColor:[UIColor  redColor]];

這樣使用appearance的好處就顯而易見了,因為這個設定是一個全域性的效果,一處設定之後在其他地方都無需再設定。實際上,appearance的作用就是統一外觀設定。

那是否是所有的控制元件或者屬性都可以這樣設定尼?

實際上能使用appearance的地方是在方法或者屬性後面有UI_APPEARANCE_SELECTOR巨集的地方

@property(nonatomic,assign) UIBarStyle barStyle UI_APPEARANCE_SELECTOR 
- (void)setTitleTextAttributes:(nullable NSDictionary<NSString *,id> *)attributes forState:(UIControlState)state NS_AVAILABLE_IOS(5_0) UI_APPEARANCE_SELECTOR;

簡單使用

如果我們自定義的檢視也想要一個全域性的外觀設定,那麼使用UIAppearancel來實現非常的方便,接下來就以一個小demo實現。

自定義一個繼承自UIView的CardView,CardView中新增兩個SubViewleftViewrightView,高度和CardView一樣,寬度分別佔據一半。

然後在.h檔案中提供修改兩個子檢視顏色的API,並新增UI_APPEARANCE_SELECTOR巨集

@property (nonatomic, strong)UIColor * leftColor UI_APPEARANCE_SELECTOR;
@property (nonatomic, strong)UIColor * rightColor UI_APPEARANCE_SELECTOR;

在.m檔案中重寫他們的setter方法設定兩個子檢視的顏色

- (void)setLeftColor:(UIColor *)leftColor {
    _leftColor = leftColor;
    self.leftView.backgroundColor = _leftColor;
}

- (void)setRightColor:(UIColor *)rightColor {
    _rightColor = rightColor;
    self.rightView.backgroundColor = _rightColor;
}

提供兩個VC,在第一個VC的viewDidLoad方法中進行全域性的顏色設定

- (void)viewDidLoad {
    [super viewDidLoad];
    
    [CardView appearance].leftColor = [UIColor redColor];
    [CardView appearance].rightColor = [UIColor yellowColor];
}

分別在兩個VC的touchesBegan方法中初始化和新增CardView檢視

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    
    CardView * cardView = [[CardView alloc]initWithFrame:CGRectMake(20, 100, 200, 100)];
    [self.view addSubview:cardView];
}

然後執行之後發現兩個VC中的CardView的顏色效果是相同的。


3738156-362144d05f12843f.jpeg
image

3738156-de1206e541fcc6ae.jpeg
image
UIAppearance修改某一型別控制元件的全部例項和部分例項

當然UIAppearance不僅可以修改某一型別控制元件的全部例項,也可以修改部分例項,開發者只需要使用正確的 API 即可

比如之前我們在demo中的第一個介面改變CardViewleftColor的全部例項的時候是這樣做的

 [CardView appearance].leftColor = [UIColor redColor];

這是使用了這個API

+ (instancetype)appearance;

如果我只想在修改部分例項需要使用另外的API

+ (instancetype)appearanceWhenContainedInInstancesOfClasses:(NSArray<Class <UIAppearanceContainer>> *)containerTypes NS_AVAILABLE_IOS(9_0);

比如如果第二個VC是以presentViewController的方式跳轉的,只想修改第一個介面上的CardViewleftColor可以在上述程式碼後面增加如下程式碼:

[CardView appearanceWhenContainedInInstancesOfClasses:@[[UINavigationController class]]].leftColor = [UIColor greenColor];

執行之後第一個介面的效果為:

3738156-fd71b3031b2766bd.jpeg
image

第二個介面不受影響。


深入剖析UIAppearance

會使用某個東西來達到效果只是一個初步的學習,接下來去看看UIAppearance究竟是一個什麼東西。

檢視API發現iOS5.0之後提供的不僅是UIAppearance,還有另外一個叫做UIAppearanceContainer的類,實際上他們都是protocol

@protocol UIAppearanceContainer <NSObject> @end

@protocol UIAppearance <NSObject>
    ...
    ...
@end

顯然蘋果的思路是:讓 UIAppearance 成為一個可以返回代理的協議,通過它可以把任何配置轉發給特定類的例項。

這樣做的好處是:UIAppearance 可以處理所有型別的UI控制元件,無論它是 UIView 的子類,還是包含了檢視例項的非 UIView 控制元件。

UIAppearance和UIAppearanceContainer的API

使用UIApearance 協議(Protocol)需實現這幾個方法:

// 返回接受外觀設定的代理
+ (instancetype)appearance;

// 當出現在某個類的出現時候才會改變
+ (instancetype)appearanceWhenContainedInInstancesOfClasses:(NSArray<Class <UIAppearanceContainer>> *)containerTypes NS_AVAILABLE_IOS(9_0);

// 針對不同 trait 下的應用的 apperance 進行很簡單的設定
+ (instancetype)appearanceForTraitCollection:(UITraitCollection *)trait NS_AVAILABLE_IOS(8_0);

+ (instancetype)appearanceForTraitCollection:(UITraitCollection *)trait whenContainedInInstancesOfClasses:(NSArray<Class <UIAppearanceContainer>> *)containerTypes  NS_AVAILABLE_IOS(9_0);

// 已經廢棄的方法
+ (instancetype)appearanceWhenContainedIn:(nullable Class <UIAppearanceContainer>)ContainerClass, ... NS_REQUIRES_NIL_TERMINATION NS_DEPRECATED_IOS(5_0, 9_0, "Use +appearanceWhenContainedInInstancesOfClasses: instead") __TVOS_PROHIBITED;

+ (instancetype)appearanceForTraitCollection:(UITraitCollection *)trait whenContainedIn:(nullable Class <UIAppearanceContainer>)ContainerClass, ... NS_REQUIRES_NIL_TERMINATION NS_DEPRECATED_IOS(8_0, 9_0, "Use +appearanceForTraitCollection:whenContainedInInstancesOfClasses: instead") __TVOS_PROHIBITED;

對於後面兩個appearanceForTraitCollection方法是用於解決 Size Classes 的問題而誕生的,通過這兩個API,我們可以控制在不同螢幕尺寸下的樣式。

而沒有內容的UIAppearanceContainerProtocol是什麼尼?

UIAppearanceContainer協議並沒有任何約定方法。因為它只是作為一個容器。
比如 UIView 實現了 UIAppearance的協議,既可以獲取外觀代理,也可以作為外觀容器。而 UIViewController 則是僅實現了 UIAppearanceContainer 協議,很簡單,它本身是控制器而不是 view,作為容器,為UIView等服務。

事實上 所有的檢視類都繼承自 UIView,UIView 的容器也基本上是 UIView 或 UIViewController,基本不需要自己去實現這兩個協議。對於需要支援使用 appearance 來設定的屬性,在屬性後增加 UI_APPEARANCE_SELECTOR 巨集宣告即可。

UIAppearance深入挖掘

接下來去看看UIAppearance的呼叫過程。
繼續使用之前的demo,在兩個setter方法上加上斷點


3738156-c8b53d2f1e480718.jpeg
image

執行的時候會發現viewDidLoad方法裡面的這兩句程式碼並沒有呼叫setter方法

    [CardView appearance].leftColor = [UIColor redColor];
    [CardView appearance].rightColor = [UIColor yellowColor];

而當CardView檢視被加到主檢視(容器)的時候才走了setter方法,這說明:
在通過appearance設定屬性的時候,並不會生成例項,立即賦值,而需要檢視被加到檢視tree中的時候才會生產例項

所以使用 UIAppearance 只有在檢視新增到 window 時才會生效,對於已經在 window 中的檢視並不會生效。因此,對於已經在 window 裡的檢視,可以採用從檢視裡移除並再次新增回去的方法使得 UIAppearance 的設定生效。

方法的呼叫棧如下:

3738156-d1f563ce4f3b2fe6.jpeg
image

不難看出appearance 設定的屬性,都以 Invocation 的形式儲存到 _UIApperance 類中,等到檢視樹 performUpdates 的時候,會去檢查有沒有相關的屬性設定,有則 invoke。所以使用 UIAppearance 只有在檢視新增到 window 時才會生效。

總結如下:

每一個實現 UIAppearance 協議的類,都會有一個 _UIApperance 例項,儲存著這個類通過 appearance 設定屬性的 invocations,在該類被新增或應用到檢視樹上的時候,它會檢查並呼叫這些屬性設定。這樣就實現了讓所有該類的例項都自動統一屬性。appearance 只是起到一個代理作用,在特定的時機,讓代理替所有例項做同樣的事。

虛無縹緲的UI_APPEARANCE_SELECTOR

前面說到使用的時候需要在屬性後增加 UI_APPEARANCE_SELECTOR 巨集宣告支援使用 UIAppearance 來設定的屬性。但是會發現它其實什麼也沒幹:

#define UI_APPEARANCE_SELECTOR __attribute__((annotate("ui_appearance_selector")))

既然它什麼多沒做,那麼我們在demo程式碼中將UI_APPEARANCE_SELECTOR去掉試試。結果會發現效果是一樣的。但是蘋果官方說了這個是must be:

To support appearance customization, a class must conform to the UIAppearanceContainer protocol and relevant accessor methods must be marked with UI_APPEARANCE_SELECTOR.

所以還是加上比較號,或許在未來的iOS版本中,這些沒有被UI_APPEARANCE_SELECTOR所marked的屬性就不能使用UIAppearance了尼。

相關文章