UIAppearance漫谈

前言

在一些app中会涉及到更改外观设置的功能,最普遍的就是夜间模式和白天模式的切换,而对于外观的更改必定是一个全局的东西。在iOS5以前,想要实现这样的效果是比较困难的,而再iOS5的时候Apple推出了UIAppearance,使得外观的自定义更加容易实现。

通常某个app都有自己的主题外观,而在自定义导航栏的时候或许是使用到如下面的代码:

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

或者

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

这样使用appearance的好处就显而易见了,因为这个设置是一个全局的效果,一处设置之后在其他地方都无需再设置。实际上,appearance的作用就是统一外观设置。

那是否是所有的控件或者属性都可以这样设置尼?

实际上能使用appearance的地方是在方法或者属性后面有UI_APPEARANCE_SELECTOR宏的地方

1
@property(nonatomic,assign) UIBarStyle barStyle UI_APPEARANCE_SELECTOR
1
- (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宏

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

在.m文件中重写他们的setter方法设置两个子视图的颜色

1
2
3
4
5
6
7
8
9
- (void)setLeftColor:(UIColor *)leftColor {
_leftColor = leftColor;
self.leftView.backgroundColor = _leftColor;
}

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

提供两个VC,在第一个VC的viewDidLoad方法中进行全局的颜色设置

1
2
3
4
5
6
- (void)viewDidLoad {
[super viewDidLoad];

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

分别在两个VC的touchesBegan方法中初始化和添加CardView视图

1
2
3
4
5
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {

CardView * cardView = [[CardView alloc]initWithFrame:CGRectMake(20, 100, 200, 100)];
[self.view addSubview:cardView];
}

然后运行之后发现两个VC中的CardView的颜色效果是相同的。

UIAppearance修改某一类型控件的全部实例和部分实例

当然UIAppearance不仅可以修改某一类型控件的全部实例,也可以修改部分实例,开发者只需要使用正确的 API 即可

比如之前我们在demo中的第一个界面改变CardViewleftColor的全部实例的时候是这样做的

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

这是使用了这个API

1
+ (instancetype)appearance;

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

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

比如如果第二个VC是以presentViewController的方式跳转的,只想修改第一个界面上的CardViewleftColor可以在上述代码后面增加如下代码:

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

运行之后第一个界面的效果为:

第二个界面不受影响。


深入剖析UIAppearance

会使用某个东西来达到效果只是一个初步的学习,接下来去看看UIAppearance究竟是一个什么东西。

查看API发现iOS5.0之后提供的不仅是UIAppearance,还有另外一个叫做UIAppearanceContainer的类,实际上他们都是protocol

1
2
3
4
5
6
@protocol UIAppearanceContainer <NSObject> @end

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

显然苹果的思路是:让 UIAppearance 成为一个可以返回代理的协议,通过它可以把任何配置转发给特定类的实例。

这样做的好处是:UIAppearance 可以处理所有类型的UI控件,无论它是 UIView 的子类,还是包含了视图实例的非 UIView 控件。

UIAppearance和UIAppearanceContainer的API

使用UIApearance 协议(Protocol)需实现这几个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 返回接受外观设置的代理
+ (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方法上加上断点

运行的时候会发现viewDidLoad方法里面的这两句代码并没有调用setter方法

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

而当CardView视图被加到主视图(容器)的时候才走了setter方法,这说明:
在通过appearance设置属性的时候,并不会生成实例,立即赋值,而需要视图被加到视图tree中的时候才会生产实例

所以使用 UIAppearance 只有在视图添加到 window 时才会生效,对于已经在 window 中的视图并不会生效。因此,对于已经在 window 里的视图,可以采用从视图里移除并再次添加回去的方法使得 UIAppearance 的设置生效。

方法的调用栈如下:

不难看出appearance 设置的属性,都以 Invocation 的形式存储到 _UIApperance 类中,等到视图树 performUpdates 的时候,会去检查有没有相关的属性设置,有则 invoke。所以使用 UIAppearance 只有在视图添加到 window 时才会生效。

总结如下:

每一个实现 UIAppearance 协议的类,都会有一个 _UIApperance 实例,保存着这个类通过 appearance 设置属性的 invocations,在该类被添加或应用到视图树上的时候,它会检查并调用这些属性设置。这样就实现了让所有该类的实例都自动统一属性。appearance 只是起到一个代理作用,在特定的时机,让代理替所有实例做同样的事。

虚无缥缈的UI_APPEARANCE_SELECTOR

前面说到使用的时候需要在属性后增加 UI_APPEARANCE_SELECTOR 宏声明支持使用 UIAppearance 来设置的属性。但是会发现它其实什么也没干:

1
#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了尼。