使用ASDK进行布局

上一篇文章中,我们跟着源码,走了一遍ASDK异步绘制的流程,本篇将初步介绍/使用ASDK的布局系统,来探索并学习一下这个由Flexbox衍生而来的布局系统。

如果你从未了解过ASDK的布局系统,那么阅读官方文档或者是通过这个小游戏来入门,会是一个不错的选择。

本文基于AsyncDisplayKit 1.9.90,即将发布的2.0版本可能会有变动

Getting Started

Scott Goodson在介绍ASDK的各种宣讲会中反复提到,UIKit是一个非常强大的库,为了优化性能而摒弃这个库,重新写一套UI绘制渲染系统是没必要的,因此,ASDK更像是对UIKit的上层封装,但在布局系统这方面,ASDK则是完全丢弃了苹果的Autolayout或是VFL,转而自己实现了一套布局系统,在这套布局系统中,有以下几个核心概念:

1.ASLayoutable Protocol

顾名思义,可以被Layout的对象需要遵循这个protocol,协议的核心是结合各项配置,实现- (ASLayout *)measureWithSizeRange:(ASSizeRange)constrainedSize;方法,返回一个ASLayout对象供布局系统布局。ASDisplayNode及其子类、ASLayoutSpec及其子类,都遵循这个协议。

2.ASLayout

存储着ASDK可以“理解”的布局信息,供布局系统进行布局,这个类一般不会直接接触到(除非你需要自己实现一个LayoutSpec),基本可以理解为,这个类存储了以下三个重要信息:**layout信息所作用的对象(layoutableObject)、layout的位置(position)、layout的大小(size)**。

3.ASLayoutSpec

ASLayout类存储着布局的信息,但若所有布局都要靠我们自己创建ASLayout对象,那几乎也就和纯手算然后setFrame没有太大区别了,ASLayoutSpec及其子类可以看做是一个上层接口,让开发者不必考虑复杂的构建ASLayout对象的过程,而通过这些LayoutSpec类来构造自己的布局,这个类及其子类将会是我们在开发的时候见到最多的。

理解ASLayoutSpec

利用ASDK的布局引擎所能获得的性能提升是不言而喻的,相比起在storyboard中拉几个constraints,亦或是借用Masonry在代码中完成约束的建立,ASDK的布局系统还是稍稍复杂一些,先来Demo最终的效果:

Demo最终展现了一系列人物信息,并在点击”View Photo”按钮后,下载一张“高清写真”,按比例显示出来,同时,cell也相应的自动增高。

在单纯利用UIKit布局的情况下,我们通常会这样做:从Storyboard中拉一个tableView,而后拉一个prototype cell,拖入UIImageView、UIButton、UILabel等控件,创建约束,而后创建一个UITableViewCell子类,拉取outlet,并在程序运行时为cell加载数据,为了让cell能自适应高度,我们可能还需要为estimatedRowHeight进行赋值等等。

而使用ASDK的布局引擎,我们大致要做如下工作:

为了让ASDK最大程度的进行性能优化,将UITableView转为ASTableNode,因为只有在诸如ASTableNode这样的NodeContainer中,其中的content才能正确获得Intelligent Preloading的相关状态更新,然后,我们创建一个ASCellNode子类,来完成我们的人物头像、信息、按钮以及动态下载图片的逻辑。

首先,我们在初始化的UIViewController子类中,加入ASTableNode:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- (void)viewDidLoad {
[super viewDidLoad];
// Do any additional setup after loading the view, typically from a nib.

self.title = @"Person of Interest";

[self generateData];

_tableNode = [[ASTableNode alloc] initWithStyle:UITableViewStylePlain];

_tableNode.delegate = self;
_tableNode.dataSource = self;

[self.view addSubnode:_tableNode];
}


ASDK为UIView加入了一个Category,加入了addSubnode:接口,但对于一般的UIView实例而言,这里和[view addSubview:node.view]并无区别。

而后,就像我们实现UITableViewDataSource一样,我们实现以下几个protocol方法(self.characters为数据源):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
-(NSInteger)numberOfSectionsInTableView:(UITableView *)tableView{
return 1;
}

-(NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section{
return self.characters.count;
}

-(ASCellNodeBlock)tableView:(ASTableView *)tableView nodeBlockForRowAtIndexPath:(NSIndexPath *)indexPath{

__weak typeof(self) wself = self;

return ^ASCellNode *{
InfoCellNode *node = [[InfoCellNode alloc] initWithCharacterInfo:wself.characters[indexPath.row]];
node.delegate = wself;
return node;
};

}


其中,```objc
-(ASCellNodeBlock)tableView:nodeBlockForRowAtIndexPath:

1
2
3
4
5

类似于我们熟悉的`cellForRowAtIndexPath`,为ASTableNode返回一个ASCellNode实例,其实,与`cellForRowAtIndexPath`更相近的应该是另一个方法:

```objc
-(ASCellNode *)tableView:nodeForRowAtIndexPath:

上述两个方法唯一的区别就是:为了提高性能,nodeBlockForRowAtIndexPath:方法的调用不一定在主线程,而nodeForRowAtIndexPath:则一定是在主线程调用的,因此,如果你的cellNode的构造直接依赖于UIView/CALayer,那么你就只能调用后者来构造你的cellNode了。

接下来看看我们的cell类InfoCellNode是如何实现的:

可以看到,除了Label和Button,我们有两个可能出现图片的地方,分别是头像和大图,两张图片都有一定程度的圆角,在UIKit中,实现圆角效果最常见的方法就是设置layer.cornerRadius,然而这样的做法会带来不小的性能消耗,我们此处采用的是使用ASDK的ASImageNode所提供的imageModificationBlock,来实现圆角效果,更多有关圆角的性能问题,官方文档的这里也有详细的说明。

我们来看看两个ASNetworkImageNode的初始化代码:(代码在InfoCellNodeinit方法中)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
        _headerNode = [ASNetworkImageNode new];
_headerNode.delegate = self;
_headerNode.defaultImage = [self placeholderImage];
_headerNode.imageModificationBlock = ^UIImage *(UIImage *originalImg){
UIGraphicsBeginImageContext(originalImg.size);

UIBezierPath *path = [UIBezierPath
bezierPathWithRoundedRect:CGRectMake(0, 0, originalImg.size.width, originalImg.size.height)
cornerRadius:MIN(originalImg.size.width,originalImg.size.height)/2];

[path addClip];

[originalImg drawInRect:CGRectMake(0, 0, originalImg.size.width, originalImg.size.height)];

UIImage *refinedImg = UIGraphicsGetImageFromCurrentImageContext();

UIGraphicsEndImageContext();

return refinedImg;
};

[self addSubnode:_headerNode];

_photoNode = [AnimateNetworkImageNode new];
_photoNode.delegate = self;
_photoNode.backgroundColor = [UIColor clearColor];
// _photoNode.defaultImage = [self placeholderImage];
_photoNode.imageModificationBlock = ^UIImage *(UIImage *originalImg){

//与头像类似的截取圆角逻辑。
};

[self addSubnode:_photoNode];


_detailNode = [ASButtonNode new];
[_detailNode setAttributedTitle:[[NSAttributedString alloc] initWithString:@"View Photo" attributes:[self buttonNormalAttributes]] forState:ASControlStateNormal];
_detailNode.hitTestSlop = UIEdgeInsetsMake(-6, -6, -6, -6);

[_detailNode addTarget:self action:@selector(showPhoto) forControlEvents:ASControlNodeEventTouchUpInside];
[self addSubnode:_detailNode];
//...其他Node初始化



此处,我们为头像设置了一个defaultImage属性,也就是当图片尚未下载完的时候所显示的Placeholder,注意,此处我注释掉了_photoNode.defaultImage = ...的逻辑,主要是因为此处_photoNode需要根据下载下来的图片进行等比缩放,而在设置了defaultImage之后,缩放功能出现了一些问题,已经有国外友人在github上面提了issue了。

我们在buttonNode响应事件中,如果图片正在下载或已经下载完成,我们直接让cellNode重新布局,否则将大图的URL赋值给_photoNode,在其完成下载的delegate回调中,我们迫使cellNode重新布局:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
-(void)showPhoto{

_show = !_show;
NSString *title = _show ? @"Hide Photo" : @"View Photo";

[_detailNode setAttributedTitle:[[NSAttributedString alloc] initWithString:title attributes:[self buttonNormalAttributes]] forState:ASControlStateNormal];


if (!_photoNode.URL && [_info.photoUrl length]) {
_photoNode.URL = [NSURL URLWithString:_info.photoUrl];
}else{
[self setNeedsLayout];
}

}


//Delegate
-(void)imageNode:(ASNetworkImageNode *)imageNode didLoadImage:(UIImage *)image{

[self setNeedsLayout];

}

另外需要注意的是,文本控件ASTextNode没有.text属性,也没有.textAlignment,对其文本的设置统一成对其.attributedText属性的设置,字体、字体颜色、对齐方式都在初始化NSAttributedStringattributes中进行设置。

ASDK 2.0之前的属性.usesImplicitHierarchyManagement或2.0之后的.automaticallyManagesSubnodes提供了很好地Automatic Subnode Management,我们不需要显示的调用addSubnode:,ASDK会在布局的时候自动检测,未被布局的node将不会被加入node hierarchy

接下来就是布局的过程了,我们再看看我们的布局:

我们可以先大致将整个Layout分为两部分:左边是头像以及按钮,右边是演员名字、角色名字,以及可能存在的大图。

对于左边我们希望:头像与按钮纵向排列,并在竖直方向上置顶。

对于右边:演员名、角色名、大图纵向排列。

同时:我们希望当Label中的文本非常长的时候,我们能够进行截断/换行。

首先,我们从简单的左侧布局做起,我们需要纵向布局,因此很自然想到使用ASStackLayoutSpec来进行布局,同时,我们需要让头像控件保持固定大小,而不是根据实际图片尺寸缩放,因此我们要指定_headerNode的大小,来看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
-(ASLayoutSpec *)layoutSpecThatFits:(ASSizeRange)constrainedSize{

NSMutableArray *leftItems = [NSMutableArray new];
_headerNode.preferredFrameSize = CGSizeMake(60, 60);

[leftItems addObject:_headerNode];

if ([_info.photoUrl length] > 0) {
[leftItems addObject:_detailNode];
}
ASStackLayoutSpec *leftStack = [ASStackLayoutSpec stackLayoutSpecWithDirection:ASStackLayoutDirectionVertical
spacing:6
justifyContent:ASStackLayoutJustifyContentStart
alignItems:ASStackLayoutAlignItemsCenter
children:leftItems];


//右侧布局 ...

}


我们指定纵向Stack,则justifyContent参数指定的就是竖直方向上该Stack Children布局的相对位置,而alignItems则是水平方向上的对齐位置(这里的这些表现,和textAlignment很相似)。

在ASDK 2.0版本中,.preferredFrameSize被改为widthheight等其他一系列属性,详见最新版头文件

对于右侧的布局,稍稍复杂一些:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
-(ASLayoutSpec *)layoutSpecThatFits:(ASSizeRange)constrainedSize{

//左侧布局 ...

NSMutableArray *rightItems = [NSMutableArray new];

ASStackLayoutSpec *infoStack = [ASStackLayoutSpec stackLayoutSpecWithDirection:ASStackLayoutDirectionVertical
spacing:2
justifyContent:ASStackLayoutJustifyContentCenter
alignItems:ASStackLayoutAlignItemsEnd
children:@[_actorNode,_roleNode]];

infoStack.spacingBefore = 20;
infoStack.spacingAfter = 6;

[rightItems addObject:infoStack];

if(_photoNode.image && _show){
ASRatioLayoutSpec *photoRatio = [ASRatioLayoutSpec ratioLayoutSpecWithRatio:_photoNode.image.size.height / _photoNode.image.size.width child:_photoNode];
photoRatio.alignSelf = ASStackLayoutAlignSelfCenter;
[rightItems addObject:photoRatio];
}

ASStackLayoutSpec *rightStack = [ASStackLayoutSpec stackLayoutSpecWithDirection:ASStackLayoutDirectionVertical
spacing:8
justifyContent:ASStackLayoutJustifyContentStart
alignItems:ASStackLayoutAlignItemsEnd
children:rightItems];


rightStack.flexGrow = rightStack.flexShrink = YES;

}




类似于左侧布局,我们为node创建了一个纵向Stack布局,我们根据当前_photoNode.image是否非空(即是否下载完毕)来决定是否将其加入布局(此处其实可以将三个node放入同一个stack中,而不必进行嵌套)。

StackLayout中指定了.spaceBefore,.spaceAfter属性的children,将在对应的布局方向上获得前置、后置的间隔。

而对某个ASLayoutable的对象指定.flexShrink,.flexGrow则是表示,该对象所处在的LayoutSpec位置不足/多余时,其是否会自动缩小/扩充。对于一个ASLayoutable容器,它的缩小/扩充实质是对其children的缩小/扩充,而在它的children中,它仅仅会尝试压缩/扩充没有intrinsicSize的child以及.flexShrink.flexGrow同样被设置成YES的child,此处我们将rightStack设为自动缩放,其实质是对其children的自动缩放,而ASRatioLayoutSpec是没有intrinsicSize的,因此可以正常缩放,但infoStack中的两个node都是ASTextNode,他们的intrinsicSize由其中的文本决定,因此此处,若不显示添加node.flexShrink = YESinfoStack将无法进行缩放。

ASDK 2.0之前,.flexShrink,.flexGrow默认为NO,2.0之后,将默认打开

最后,我们将左右拼合,塞入一个ASInsetLayout中,就完成了cell的布局:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-(ASLayoutSpec *)layoutSpecThatFits:(ASSizeRange)constrainedSize{

//左侧布局 ...

//右侧布局 ...
ASStackLayoutSpec *stack = [ASStackLayoutSpec stackLayoutSpecWithDirection:ASStackLayoutDirectionHorizontal
spacing:10
justifyContent:ASStackLayoutJustifyContentStart
alignItems:ASStackLayoutAlignItemsStart
children:@[leftStack,rightStack]];

return [ASInsetLayoutSpec insetLayoutSpecWithInsets:UIEdgeInsetsMake(6, 10, 8, 10) child:stack];

}


效果:

接着,我们将字体调整一下,看看是否能够正常显示字体过长的情况:

Wow~这可不是我们想要的,回头想一想,刚刚提到了,当空间不足时,rightStack试图压缩其中的content,但是由于_actorNode是有实际大小的,因此不能被压缩,我们只要加上_actorNode.flexShrink = YES;,即可正常触发换行逻辑_(不过这些在ASDK 2.0之后,都不需要我们手工指定了)_:

而如果在text过长时,采用截取而非换行的方式,只需要将node.maximumNumberOfLines设置为对应的最大行数,并在node.truncationMode中设置截取样式,即可得到截取的效果_(图为maximumNumberOfLines = 1 , truncationMode = NSLineBreakByTruncatingMiddle)_:

动画

以往利用UIKit+Autolayout布局,当需要动画展示布局变化的时候,我们大概是这样做的:

1
2
3
4
5
6
7
8
[UIView animateWithDuration:1 
animation:^{
view.constraint = newConstraint;
view.constraint2 = newConstraint2;
[view setNeedsLayout];
[view.superview layoutIfNeeded];
}];

但在ASDK的布局系统中,想要用动画展现布局的改变稍微麻烦一些,需要在相应的ASDisplayNode子类中重写-(void)animateLayoutTransition:方法,并将setNeedsLayout方法替换为transitionLayoutWithAnimation:,我在尝试添加动画的时候遇到了不少问题,而且相应的LayoutTransition API也会在ASDK 2.0版本中有较大的变化,届时我探索清楚之后,将再补充此部分内容。

本文中出现的代码可以在这里进行下载,若发现有不正确的地方,欢迎指正。