ios UIAppearance 协议
一、前言
iOS 上提供了一个比较强大的工具UIAppearance,我们通过UIAppearance设置一些UI的全局效果,这样就可以很方便的实现UI的自定义效果又能最简单的实现统一界面风格。
+ (id)appearance ; 这个是这个协议里最重要的方法了 .
这个方法是统一全部改,比如你可以设置UIView.appearance().backgroundColor = UIColor.orange, 这样所有View默认颜色就都是橘色。
二、主题设置的前提
为什么我们可以给它们设置主题属性呢?哪些对象 哪些属性 可以设置主题属性呢?
1. 那些控件和类,可以设置主题呢?
回答:只要遵守了UIAppearance协议
的类,都可以设置主题
查看UIView的头文件,可得,UIView可以设置主题,那么不是所有继承UIView的控件就都可以设置主题了吗?是的
观看可得,不仅,只是控件可以设置主题,UIBarItem等只要遵守了UIAppearance协议的类,都可以设置主题
这里列举了一部分:
- UIView
- UIActivitiIndicatorView
- UIBarButtonItem
- UIBarItem
- UINavgationBar
- UIPopoverControll
- UIProgressView
- UISearchBar
- UISegmentControll
- UISlider
- UISwitch
- UITabBar
- UITabBarItem
- UIToolBar
- UIViewController
2.遵守UIAppearance协议的类的,那些属性可以设置主题呢?
通过主题对象设置属性的前提: 属性后面是否带有UI_APPEARANCE_SELECTOR
的方法
- (void)setTitleTextAttributes:(NSDictionary *)attributes forState:(UIControlState)state
NS_AVAILABLE_IOS(5_0) UI_APPEARANCE_SELECTOR;
因为并不是所以属性可以设置主题,设置主题属性是有前提的。
查看头文件可得,可以设置UITabBarItem类,发现setTitleTextAttributes:可以设置UITabBarItem文字主题
- 案例:设置所有的UITabBarItem,普通与选中状态下的文字颜色
三、运用主题appearance,是否会生效,何时会生效
1、主题会生效的场景
:先设置控件主题,后添加控件到视图上
- 添加控件时,添加的那一刻会检查主题,会根据主题设置控件 =》主题会生效
2、主题不会生效的场景
:先添加控件,后设置主题
- 控件已经添加,后设置主题,对以前的添加的控件不起作用了
如果先添加控件,后设置主题,主题失效,我们该如何解决呢?
最优方案:当然是改下调用顺序,先设置appearance,在添加控件。
当然作为脑洞或者探究原理,也可以重新把View移除,然后在添加一次,触发一次渲染,实测是生效的。(但是这样的代码,谁看谁挠头,还是不要在上线版本中写。)
三、实现原理
UIApearance
实际上是一个协议(Protocol),我们可以用它来获取一个类的外观代理(Appearance Proxy)。该协议需实现这几个方法:
+ (instancetype)appearance;
+ (instancetype)appearanceWhenContainedInInstancesOfClasses:(NSArray<Class <UIAppearanceContainer>> *)containerTypes NS_AVAILABLE_IOS(9_0);
// 详细方法见 UIKit/UIAppearance.h
另外一个与之对应的协议是 UIAppearanceContainer
,该协议并没有任何约定方法。因为它只是作为一个容器。
常见的,如 UIView 实现了 UIAppearance
这两种协议,既可以获取外观代理,也可以作为外观容器。 而 UIViewController 则是仅实现了 UIAppearanceContainer
协议,很简单,它本身是控制器而不是 view,作为容器,为 UIView 等服务。
事实上,在使用中,我们所有的视图类都继承自 UIView,UIView 的容器也基本上是 UIView 或 UIController,基本不需要自己去实现这两个协议。对于需要支持使用 appearance 来设置的属性,在属性后增加 UI_APPEARANCE_SELECTOR
宏声明即可。 文档中也有解释 UI_APPEARANCE_SELECTOR
用来标记属性用于外观代理,支持哪些类型等等。
To participate in the appearance proxy API, tag your appearance property selectors in your header with UI_APPEARANCE_SELECTOR.Appearance property selectors must be of the form:- (void)setProperty:(PropertyType)property forAxis1:(IntegerType)axis1 axis2:(IntegerType)axis2 axisN:(IntegerType)axisN;- (PropertyType)propertyForAxis1:(IntegerType)axis1 axis2:(IntegerType)axis2 axisN:(IntegerType)axisN;You may have no axes or as many as you like for any property. PropertyType may be any standard iOS type: id, NSInteger, NSUInteger, CGFloat, CGPoint, CGSize, CGRect, UIEdgeInsets or UIOffset. IntegerType must be either NSInteger or NSUInteger; we will throw an exception if other types are used in the axes.
翻译一下:
要参与外观代理 API,请在头文件中用UI_APPEARANCE_SELECTOR标记您的外观属性选择器。
外观属性选择器必须采用以下形式:
- (void) setProperty:(属性类型) property forAxis1:(整数类型) axis1 axis2:(整数类型) axis2 axisN:(整数类型) axisN;
- (属性类型) propertyForAxis1:(整数类型) axis1 axis2:(整数类型) axis2 axisN:(整数类型) axisN;对于任何属性,您可以没有参数,也可以有任意多个参数。属性类型可以是任何标准的 iOS 类型:id、NSInteger、NSUInteger、CGFloat、CGPoint、CGSize、CGRect、UIEdgeInsets 或 UIOffset。整数类型必须是 NSInteger 或 NSUInteger;如果在参数中使用其他类型,我们将抛出异常。
demo验证原理
写一个简单的小 Demo,自定义 CardView,有两个 subview: headerView 和 footerView,声明 2 个属性:
@property (nonatomic, strong) UIColor *headerColor UI_APPEARANCE_SELECTOR;
@property (nonatomic, strong) UIColor *bodyColor UI_APPEARANCE_SELECTOR;
Setter 方法都加断点调试:
- (void)setHeaderColor:(UIColor *)headerColor
{_headerColor = headerColor;self.headerView.backgroundColor = _headerColor;
}- (void)setBodyColor:(UIColor *)bodyColor
{_bodyColor = bodyColor;self.bodyView.backgroundColor = _bodyColor;
}
在 ViewController 的 view 中加一个按钮,点击则创建并添加 CardView,每行代码均加断点:
- (IBAction)createButtonTouched:(id)senderCardView *cardView = [[CardView alloc] initWithFrame:CGRectMake(20, 100, 80, 120)];[self.view addSubview:cardView];cardView.headerColor = [UIColor greenColor];
}
另外,在较早的时候,添加 appearance 设置:
[CardView appearance].headerColor = [UIColor redColor];
[CardView appearance].bodyColor = [UIColor orangeColor];
运行发现,在通过 appearance 设置属性的时候,并没有调用 setter 方法,由此可知 appearance 并不会生成实例,立即赋值。当 cardView 被添加到主视图(即视图树)中去的时候,才依次调用两个 setter 方法,调用栈如下
从 15 至 11 可以看出确实是加入到视图树中才触发的,从 7 至 2 可以基本猜测出,appearance 设置的属性,都以 Invocation 的形式存储到 _UIApperance 类中(事实上 _UIApperance 类中就有一个 _appearanceInvocations 数组),等到视图树 performUpdates 的时候,会去检查有没有相关的属性设置,有则 invoke。(这里可以看看 NSInvocation)
紧接着,它进入了 bodyColor 的 setter
然后,当手动设置属性的时候,它是直接进入 setter 的。
到这里,基本清晰了。
每一个实现 UIAppearance 协议的类,都会有一个 _UIApperance 实例,保存着这个类通过 appearance 设置属性的 invocations,在该类被添加或应用到视图树上的时候,它会检查并调用这些属性设置。这样就实现了让所有该类的实例都自动统一属性。
当然,如果后面又手动设置了属性,肯定会覆盖了。从上面可以知道,appearance 生效是在被添加到视图树时,所以,在此之后设置 appearance,则不会起作用,而在手动设置属性之后被添加到视图树上,手动设置的会被覆盖。appearance 只是起到一个代理作用,在特定的时机,让代理替所有实例做同样的事。
尝试一下,去掉 UI_APPEARANCE_SELECTOR
宏声明,然后通过 appearance 设置属性,会怎么样呢? 测试后发现,结果是一样的。也就是说 UI_APPEARANCE_SELECTOR
并没有干什么事,正如文档所说,只是 tag 一下。看 UI_APPEARANCE_SELECTOR
宏定义如下
#define UI_APPEARANCE_SELECTOR __attribute__((annotate("ui_appearance_selector")))
由此可见,UI_APPEARANCE_SELECTOR
真的啥都没干。。只是为了代码可读性,方便开发者使用,还是在需要的地方加上它。
原理小结:
1. 调用 +appearance
获得 proxy 对象,这个 appearance
对象并不是真正的 UIView
的实例,而是 UIKit 给你的一个 proxy,它不会马上设置属性,而是记录这个调用。
2. UIKit 使用 NSInvocation 保存调用信息,包括方法名和参数值。UIAppearance
会创建一个 NSInvocation
,记录你设置了 selector: 和
value, 在合适的时机重新调用设置。
3.在控件显示时统一“回放”设置,当后续创建某个 UIView
实例时,UIKit 会:
-
检查当前
UIView
类型是否有 appearance 设置; -
遍历已记录的 selector;
-
创建
NSInvocation
,将目标值更新为这个实例的属性