iOS7 开发弹簧式列表的制作

logo.png

2018-06-26
来源:http://mobile.51cto.com/hot-435097.htm
onevcat代码:https://github.com/onevcat/VVSpringCollectionViewFlowLayout

iOS7引入UIKit Dynamics,我们还可以结合ScrollView作出一些以前不太可能或者需要花费很大力气来实现的效果,包括带有重力的swipe或者是类似新的信息app中的带有弹簧效果聊天泡泡等。

UIScrollView可以说是UIKit中最重要的类之一了,包括UITableView和UICollectionView等重要的数据容器类都是UIScrollView的子类。在历年的WWDC上,UIScrollView和相关的API都有专门的主题介绍,也可以看出这个类的使用和变化之快。今年也不例外,因为iOS7完全重新定义了UI,这使得UIScrollView里原来不太会使用的一些用法和实现的效果在新的系统中得到了很好的表现。另外,由于引入了UIKit Dynamics,我们还可以结合UIScrollView作出一些以前不太可能或者需要花费很大力气来实现的效果,包括带有重力的swipe或者是类似新的信息app中的带有弹簧效果聊天泡泡等。如果您还不太了解iOS7中信息app的效果,这里有一张gif图可以帮您大概了解一下:

iOS7中信息app的弹簧效果

这次笔记的内容主要就是实现一个这样的效果。为了避免重复造轮子,我对这个效果进行了简单的封装,并连同这篇笔记的demo一起扔在了Github上,有需要的童鞋可以在这里自取。

iOS7的SDK中Apple最大的野心其实是想用SpriteKit来结束iOS平台游戏开发(至少是2D游戏开发)的乱战,统一游戏开发的方式并建立良性社区。而UIKit Dynamics,个人猜测Apple在花费力气为SpriteKit开发了物理引擎的同时,发现在UIKit中也可以使用,并能得到不错的效果,于是顺便革新了一下设计理念,在UI设计中引入不少物理的概念。在iOS系统中,最为典型的应用是锁屏界面打开相机时中途放弃后的重力下坠+反弹的效果,另一个就是信息应用中的假如弹性的消息列表了。弹性列表在我自己上手试过以后觉得表现形式确实很生动,可以消除原来列表那种冷冰冰的感觉,是有可能在今后设计中被大量用到的,因此决定学上一学。

首先我们需要知道要如何实现这样一种效果,我们会用到哪些东西。毋庸置疑,如果不使用UIKit Dynamics的话,自己从头开始来完成会是一件非常费力的事情,你可能需要实现一套位置计算和物理模拟来使效果看起来真实滑润。而UIKit Dynamics中已经给我们提供了现成的弹簧效果,可以用UIAttachmentBehavior进行实现。另外,在说到弹性效果的时候,我们其实是 在描述一个列表中的各个cell之间的关系,对于传统的UITableView来说,描述UITableViewCell之间的关系是比较复杂的(因为 Apple已经把绝大多数工作做了,包括计算cell位置和位移等。使用越简单,定制就会越麻烦在绝大多数情况下都是真理)。而 UICollectionView则通过layout来完成cell之间位置关系的描述,给了开发者较大的空间来实现布局。另外,UIKit Dynamics为UICollectionView做了很多方便的Catagory,可以很容易地“指导”UICollectionView利用加入物 理特性计算后的结果,在实现弹性效果的时候,UICollectionView是我们不二的选择。

如果您在阅读这篇笔记的时候遇到困难的话,建议您可以看看我之前的一些笔记,包括今年的UIKit Dynamics的介绍和去年的UICollectionView介绍

话不多说,我们开工。首先准备一个UICollectionViewFlowLayout的子类(在这里叫做 VVSpringCollectionViewFlowLayout),然后在ViewController中用这个layout实现一个简单的 collectionView:

ViewController.m 

@interface ViewController ()<UICollectionViewDataSource, UICollectionViewDelegate> 
@property (nonatomic, strong) VVSpringCollectionViewFlowLayout *layout; 
@end 

static NSString *reuseId = @"collectionViewCellReuseId"; 

@implementation ViewController 
- (void)viewDidLoad { 
      [super viewDidLoad]; 

      self.layout = [[VVSpringCollectionViewFlowLayout alloc] init]; 
      self.layout.itemSize = CGSizeMake(self.view.frame.size.width, 44); 
      UICollectionView *collectionView = [[UICollectionView alloc] initWithFrame:self.view.frame collectionViewLayout:self.layout]; 
      collectionView.backgroundColor = [UIColor clearColor]; 
      [collectionView registerClass:[UICollectionViewCell class] forCellWithReuseIdentifier:reuseId]; 
      collectionView.dataSource = self; 
      [self.view insertSubview:collectionView atIndex:0]; 
} 

#pragma mark - UICollectionViewDataSource 
- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section  { 
    return 50; 
} 
  
- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath  { 
    UICollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:reuseId forIndexPath:indexPath]; 
     //Just give a random color to the cell. See <a href="\"https://gist.github.com/kylefox/1689973\"" target="\"_blank\"">https://gist.github.com/kylefox/1689973</a> 
    cell.contentView.backgroundColor = [UIColor randomColor]; 
    return cell; 
} 
@end 

这部分没什么可以多说的,现在我们有一个标准的FlowLayout的UICollectionView了。通过使用 UICollectionViewFlowLayout的子类来作为开始的layout,我们可以节省下所有的初始cell位置计算的代码,在上面代码的情况下,这个collectionView的表现和一个普通的tableView并没有太大不同。接下来我们着重来看看要如何实现弹性的layout。对于弹性效果,我们需要的是连接一个item和一个锚点间弹性连接的UIAttachmentBehavior,并能在滚动时设置新的锚点位置。我们在scroll的时候,只要使用UIKit Dynamics的计算结果,替代掉原来的位置更新计算(其实就是简单的scrollView的contentOffset的改变),就可以模拟出弹性的效果了。

首先在-prepareLayout中为cell添加UIAttachmentBehavior。

//VVSpringCollectionViewFlowLayout.m 
@interface VVSpringCollectionViewFlowLayout() 
@property (nonatomic, strong) UIDynamicAnimator *animator; 
@end 
   
@implementation VVSpringCollectionViewFlowLayout 
//...     
-(void)prepareLayout { 
    [super prepareLayout];  
    if (!_animator) { 
        _animator = [[UIDynamicAnimator alloc] initWithCollectionViewLayout:self]; 
        CGSize contentSize = [self collectionViewContentSize]; 
        NSArray *items = [super layoutAttributesForElementsInRect:CGRectMake(0, 0, contentSize.width, contentSize.height)]; 
        for (UICollectionViewLayoutAttributes *item in items) { 
             UIAttachmentBehavior *spring = [[UIAttachmentBehavior alloc] initWithItem:item attachedToAnchor:item.center]; 
            spring.length = 0; 
            spring.damping = 0.5; 
            spring.frequency = 0.8; 
            [_animator addBehavior:spring]; 
        } 
    } 
} 
@end 

prepareLayout将在CollectionView进行排版的时候被调用。首先当然是call一下super的prepareLayout,你 肯定不会想要全都要自己进行设置的。接下来,如果是第一次调用这个方法的话,先初始化一个UIDynamicAnimator实例,来负责之后的动画效 果。iOS7 SDK中,UIDynamicAnimator类专门有一个针对UICollectionView的Category,以使 UICollectionView能够轻易地利用UIKit Dynamics的结果。在UIDynamicAnimator.h中能够找到这个Category:

@interface UIDynamicAnimator (UICollectionViewAdditions) 
 
// When you initialize a dynamic animator with this method, you should only associate collection view layout attributes with your behaviors. 
// The animator will employ thecollection view layout’s content size coordinate system. 
- (instancetype)initWithCollectionViewLayout:(UICollectionViewLayout*)layout; 
 
// The three convenience methods returning layout attributes (if associated to behaviors in the animator) if the animator was configured with collection view layout 
- (UICollectionViewLayoutAttributes*)layoutAttributesForCellAtIndexPath:(NSIndexPath*)indexPath; 
- (UICollectionViewLayoutAttributes*)layoutAttributesForSupplementaryViewOfKind:(NSString *)kind atIndexPath:(NSIndexPath *)indexPath; 
 - (UICollectionViewLayoutAttributes*)layoutAttributesForDecorationViewOfKind:(NSString*)decorationViewKind atIndexPath:(NSIndexPath *)indexPath; 
  
@end 

于是通过-initWithCollectionViewLayout:进行初始化后,这个UIDynamicAnimator实例便和我们的 layout进行了绑定,之后这个layout对应的attributes都应该由绑定的UIDynamicAnimator的实例给出。就像下面这样:

//VVSpringCollectionViewFlowLayout.m 
@implementation VVSpringCollectionViewFlowLayout 
 
//... 

-(NSArray *)layoutAttributesForElementsInRect:(CGRect)rect { 
     return [_animator itemsInRect:rect]; 
} 
 
-(UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath { 
    return [_animator layoutAttributesForCellAtIndexPath:indexPath]; 
} 
@end 
向上拖动时的锚点变化示意

现在我们来实现这个锚点的变化。既然都是滑动,我们是不是可以考虑在UIScrollView的–scrollViewDidScroll:委托方法中来 设定新的Behavior锚点值呢?理论上来说当然是可以的,但是如果这样的话我们大概就不得不面临着将刚才的layout实例设置为 collectionView的delegate这样一个事实。但是我们都知道layout应该做的事情是给collectionView提供必要的布局 信息,而不应该负责去处理它的委托事件。处理collectionView的回调更恰当地应该由处于collectionView的controller 层级的类来完成,而不应该由一个给collectionView提供数据和信息的类来响应。在UICollectionViewLayout中,我们有一个叫做-shouldInvalidateLayoutForBoundsChange:的方法,每次layout的bounds发生变化的时 候,collectionView都会询问这个方法是否需要为这个新的边界和更新layout。一般情况下只要layout没有根据边界不同而发生变化的 话,这个方法直接不做处理地返回NO,表示保持现在的layout即可,而每次bounds改变时这个方法都会被调用的特点正好可以满足我们更新锚点的需 求,因此我们可以在这里面完成锚点的更新。

//VVSpringCollectionViewFlowLayout.m 
@implementation VVSpringCollectionViewFlowLayout 
 
//... 
 
-(BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds { 
    UIScrollView *scrollView = self.collectionView; 
    CGFloat scrollDelta = newBounds.origin.y - scrollView.bounds.origin.y; 
 
    //Get the touch point 
    CGPoint touchLocation = [scrollView.panGestureRecognizer locationInView:scrollView]; 
 
    for (UIAttachmentBehavior *spring in _animator.behaviors) { 
        CGPoint anchorPoint = spring.anchorPoint; 
 
        CGFloat distanceFromTouch = fabsf(touchLocation.y - anchorPoint.y); 
        CGFloat scrollResistance = distanceFromTouch / 500; 
 
        UICollectionViewLayoutAttributes *item = [spring.items firstObject]; 
        CGPoint center = item.center; 
 
      //In case the added value bigger than the scrollDelta, which leads an unreasonable effect 
        center.y += (scrollDelta > 0) ? MIN(scrollDelta, scrollDelta * scrollResistance) 
                                      : MAX(scrollDelta, scrollDelta * scrollResistance); 
        item.center = center; 
 
        [_animator updateItemUsingCurrentState:item]; 
    } 
    return NO; 
} 
 
@end 

首先我们计算了这次scroll的距离scrollDelta,为了得到每个item与触摸点的之间的距离,我们当然还需要知道触摸点的坐标 touchLocation。接下来,可以根据距离对每个锚点进行设置了:简单地计算了原来锚点与触摸点之间的距离distanceFromTouch, 并由此计算一个系数。接下来,对于当前的item,我们获取其当前锚点位置,然后将其根据scrollDelta的数值和刚才计算的系数,重新设定锚点的 位置。最后我们需要告诉UIDynamicAnimator我们已经完成了对冒点的更新,现在可以开始更新物理计算,并随时准备 collectionView来取LayoutAttributes的数据了。

也许你还没有缓过神来?但是我们确实已经做完了,让我们来看看实际的效果吧:


带有弹性效果的collecitonView

当然,通过调节damping,frequency和scrollResistance的系数等参数,可以得到弹性不同的效果,比如更多的震荡或者更大的幅度等等。

推荐阅读更多精彩内容