在应用中,为了提升应用的加载等待这段时间的用户感知体验,各种技术层出不穷。其中,尤以菊花图以及由它衍生各种加载动画最为突出。
对于菊花图我们自不必多说,现在对于加载的设计体验有了比菊花加载体验更棒的方法,即大家常看到的Skeleton Screen Loading,中文叫做骨架屏。
所谓Skeleton Screen Loading,即表示在页面完全渲染完成之前,用户会看到一个占位的样式,用以描绘了当前页面的大致框架,加载完成后,最终骨架屏中各个占位部分将被真实的数据替换。 其效果如下图所示:
看了Somo的源码, 这个库还是很简洁的.
比较重要的就是SomoView这个类了, 继承自UIView, 内部使用了CAGradientLayer+CABasicAnimation实现了了4种动画效果, 这个这个库的基础.
// 4种动画中的一种,都是使用CABasicAnimation
CABasicAnimation * basic = [CABasicAnimation animationWithKeyPath:@"opacity"];
basic.fromValue = @1.;
basic.toValue = @0.5;
basic.duration = 2.;
basic.repeatCount = INFINITY;
basic.autoreverses = YES;
basic.removedOnCompletion = NO;
[self.layer addAnimation:basic forKey:basic.keyPath];
// 给View添加颜色渐变的效果
-(CAGradientLayer *)somoLayer{
if (!_somoLayer) {
_somoLayer = [CAGradientLayer layer];
UIColor * color = [UIColor whiteColor];
_somoLayer.colors = @[(id)[color colorWithAlphaComponent:0.03].CGColor,
(id)[color colorWithAlphaComponent:0.09].CGColor,
(id)[color colorWithAlphaComponent:0.15].CGColor,
(id)[color colorWithAlphaComponent:0.21].CGColor,
(id)[color colorWithAlphaComponent:0.27].CGColor,
(id)[color colorWithAlphaComponent:0.30].CGColor,
(id)[color colorWithAlphaComponent:0.27].CGColor,
(id)[color colorWithAlphaComponent:0.21].CGColor,
(id)[color colorWithAlphaComponent:0.15].CGColor,
(id)[color colorWithAlphaComponent:0.09].CGColor,
(id)[color colorWithAlphaComponent:0.03].CGColor];
}
return _somoLayer;
}
还有就是UIView+SomoSkeleton, 这是UIView的一个类别, 方便外界调用,
@interface UIView (SomoSkeleton)
/// 内部用runtime给UIView添加了一个容器视图
@property (strong, nonatomic, readonly) UIView * somoContainer;
/** 是否是在加载骨架动画 */
@property (nonatomic, assign, readonly) BOOL isLoadingSkeleton;
/**开始加载骨架屏动画 */
- (void)beginSomo;
/**结束骨架屏加载, 清除容器视图 */
- (void)endSomo;
@end
内部的实现, 主要点:
- 使用runtime给UIView添加了一个容器视图somoContainer
- 开始动画的时候, 依据协议获取到一个由SomoView组成的数组, 然后把数组中的SomoView放到容器视图somoContainer上, 然后动画自然就出现了
/// 外界调用开始动画
- (void)beginSomo{
if ([self conformsToProtocol:@protocol(SomoSkeletonLayoutProtocol)] == NO) {
return;
}
if ([self respondsToSelector:@selector(somoSkeletonLayout)] == NO) {
return;
}
// 把手势关了, 外界调endSomo的时候在打开手势, 这个是个小坑, 页面上的返回也无法点击了
self.userInteractionEnabled = NO;
// 生成容器视图
[self buildContainer];
[self bringSubviewToFront:self.somoContainer];
[self setNeedsLayout];
[self layoutIfNeeded];
// 根据协议获取到需要加载的SomoView数组
NSArray<SomoView *> * somoViews = [(UIView<SomoSkeletonLayoutProtocol> *)self somoSkeletonLayout];
[self buildSkeletonSubViews:somoViews];
}
// 生成容器, 先移除旧的,在生成一个普通的UIView作为容器
- (void)buildContainer{
[self clear];
self.somoContainer = [UIView new];
}
/// 依据协议获取到的东西加入到容器View上
- (void)buildSkeletonSubViews:(NSArray<SomoView *> *)views{
for (SomoView * view in views) {
[self.somoContainer addSubview:view];
}
}
// 重写set方法,runtime关联容器视图
- (void)setSomoContainer:(UIView *)somoContainer{
somoContainer.frame = self.bounds;
UIColor * color = SomoColorFromRGBV(248.);
if ([self respondsToSelector:@selector(somoSkeletonBackgroundColor)]) {
color = [(UIView<SomoSkeletonLayoutProtocol> *)self somoSkeletonBackgroundColor];
}
somoContainer.backgroundColor = color;
[self addSubview:somoContainer];
objc_setAssociatedObject(self, kSomoContainerKey, somoContainer, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
- (UIView *)somoContainer{
return objc_getAssociatedObject(self, kSomoContainerKey);
}
上面提到了协议, 协议的内容也很简单, 只有2个方法
- - (NSArray<SomoView *> *)somoSkeletonLayout; 返回需要加载的SomoView数组,
- - (UIColor *)somoSkeletonBackgroundColor; 设置容器视图的背景色, 可选方法
@protocol SomoSkeletonLayoutProtocol<NSObject>
#pragma mark -
@required
/**
* like this:
SomoView * s0 = [[SomoView alloc] initWithFrame:CGRectMake(10, 20, 70, 70)];
SomoView * s1 = [[SomoView alloc] initWithFrame:CGRectMake(100, 30, 200, 15)];
SomoView * s2 = [[SomoView alloc] initWithFrame:CGRectMake(100, 70, 100, 15)];
return @[s0,s1,s2];
*
*
@return array of SomoViews
*/
- (NSArray<SomoView *> *)somoSkeletonLayout;
@optional
#pragma mark -
/**
* Set the view's background color when the Skeleton effect appears
————————————————————————
| **** ********* |
| **** ——+——>backgroundColor
| **** ********* |
————————————————————————
*
@return UIColor *
**/
- (UIColor *)somoSkeletonBackgroundColor;
@end
上面是针对普通的view的, 我们最常用的还是tebleView和collectView, 针对最常用的场景还做了点优化.
UITableView-skeleton
在常见场景中,数据请求未着陆前,UITableView中所有的cell都应该呈现skeleton效果。为了达到这种效果,自定义的cell中需要实现<SomoSkeletonLayoutProtocol>协议的方法
- (NSArray<SomoView *> *)somoSkeletonLayout; 。
同时Somo中有一个遵循<UITableViewDataSource,UITableViewDelegate>协议的SomoDataSourceProvider类,您只需要按照该类指定的初始化方法构造一个实例,数据未着陆前,将tableview实例的datasource和delegate指向构造出的SomoDataSourceProvider实例。当数据着陆后,将tableview的datasource和delegate指向controller或其他。
- (void)viewDidLoad {
[super viewDidLoad];
self.tableView.rowHeight = 100;
// InfoTableViewCell实现了<SomoSkeletonLayoutProtocol>协议
[self.tableView registerNib:[UINib nibWithNibName:@"InfoTableViewCell" bundle:nil] forCellReuseIdentifier:@"InfoTableViewCell"];
// 简单场景,rowHeight就是self.tableView.rowHeight
// self.provider = [[SomoDataSourceProvider alloc] initWithCellReuseIdentifier:@"InfoTableViewCell"];
// 复杂场景,可以定制每行的cell和rowHeight
self.provider = [[SomoDataSourceProvider alloc] initWithTableViewCellBlock:^UITableViewCell<SomoSkeletonLayoutProtocol> *(UITableView *tableview, NSIndexPath *indexPath) {
if (indexPath.row%3==0) {
return [tableview dequeueReusableCellWithIdentifier:@"InfoTableViewCell"];
}
return [tableview dequeueReusableCellWithIdentifier:@"InfoTableViewCell"];
} heightBlock:^CGFloat(UITableView *tableView, NSIndexPath *indexPath) {
if (indexPath.row%2 ==0) {
return 120;
} else {
return 80;
}
}];
self.tableView.dataSource = self.provider;
self.tableView.delegate = self.provider;
// 模拟网络请求,假设3s后获取到了数据
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
self.tableView.dataSource = self;
self.tableView.delegate = self;
[self.tableView reloadData];
});
}
- 注意点: 不要对SomoDataSourceProvider做定制。数据着陆后的delegate必须实现中的一个方法:
#pragma mark - 在这里必调用 endSomo
- (void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath{
[cell endSomo];
}
上面是如何使用,同样看下具体的实现, SomoDataSourceProvider都做了什么.
头文件中有2个block, 就是为了定制TableView每行的cell和行高的, 没有找到关于CollectionView的, 可能作者用CollectView比较少吧.
typedef UITableViewCell<SomoSkeletonLayoutProtocol> *(^SomoTableViewCellBlock)(UITableView *tableview, NSIndexPath *indexPath);
typedef CGFloat(^SomoTableViewCellHeightBlock)(UITableView *tableView,NSIndexPath *indexPath);
还有就是SomoDataSourceProvider遵守了4个协议, tableView的2个, collectView的2个协议.
<UITableViewDataSource,UITableViewDelegate,UICollectionViewDelegate,UICollectionViewDataSource>
其他都是一些简单的初始化方法, 到.m中看看. 以tableVIew为例,实现了协议方法, 在协议方法中会根据初始化方法的不同返回不同的cell和行高.
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section{
return self.numberOfRowsInSection;
}
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath{
if (_reuseIdentifier) {
return tableView.rowHeight;
}else{
return _heightBlock(tableView, indexPath);
}
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{
if (_reuseIdentifier) {
return [tableView dequeueReusableCellWithIdentifier:_reuseIdentifier forIndexPath:indexPath];
}else{
return _tableViewCellBlock(tableView,indexPath);
}
}
- (void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath{
[cell beginSomo];
}
在SomoDataSourceProvider中实现了tableView:willDisplayCell:方法, 里面调用了beginSomo, 在数据着陆后的delegate中, tableView:willDisplayCell:要调用的是endSomo.
至此Somo就看完了, 最核心的就是SomoView了吧, 只要一个View把SomoView添加上去, 自然就有了骨架的效果, 其他的类都是为了方便加载SomoView的.