在应用中,为了提升应用的加载等待这段时间的用户感知体验,各种技术层出不穷。其中,尤以菊花图以及由它衍生各种加载动画最为突出。

对于菊花图我们自不必多说,现在对于加载的设计体验有了比菊花加载体验更棒的方法,即大家常看到的Skeleton Screen Loading,中文叫做骨架屏。

所谓Skeleton Screen Loading,即表示在页面完全渲染完成之前,用户会看到一个占位的样式,用以描绘了当前页面的大致框架,加载完成后,最终骨架屏中各个占位部分将被真实的数据替换。 其效果如下图所示:

iOS 骨架加载 加载骨架图_iOS 骨架加载

iOS 骨架加载 加载骨架图_iOS 骨架加载_02

iOS 骨架加载 加载骨架图_加载_03

iOS 骨架加载 加载骨架图_数据_04

 

看了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的.

源码地址 :  https://github.com/HsiaohuiHsiang/Somo