<iOS>UIScrollView实践经验

UIScrollView实践经验


UIScrollView(包括它的子类UITableView和UICollectionView)是iOS开发中最常用也是最有意思的UI组件,大部分App的核心界面都是基于三者之一或三者的组合实现。


UIScrollView是UIKit中为数不多能响应滑动手势的view,相比自己用UIPanGestureReconizer实现一些基于滑动手势的效果,用UIScrollView的优势在于bounce和decelerate等特性可以让App的用户体验与iOS系统的用户体验保持一致。


UIScrollView和Auto Layout

iPhone5刚出来的时候,大部分不支持横屏的App都不需要做太多的适配工作,因为屏幕宽度没有变,tableView多个cell也不需要加code。但是在iPhone6和iPhone6 Plus发布以后,多分辨率适配终于不再是Andriod开发的专利了。于是,从iOS6起就存在Auto Layout终于有用武之地。


关于AutoLayout的基本用法不再赘述,可以参考Ray Wenderlich上的教程。但UIScrollView在Auto Layout是一个很特殊的view,对于UISrollView的subview来说,它的leading/trailing/top/bottom space是相对于UIScrollView的contentSize而不是bounds来确定的,所以当你尝试用UIScrollView和它subview的leading/trailing/top/bottom来相互决定大小的时候,就会出现[Has ambigous scrollable content width/height]的warning。正确的是用UIScrollView外部的view或UIScrollView本身的width/height确定subview的尺寸,进而确定contentSize。因为UIScrollView本身的leading/trailing/top/bottom变得不好用,所以我习惯的做法是在UIScrollView和它原来的subviews之间增加一个contentView,这样做的好处有:

a、不会在storyboard里留下error/warning

b、为subview提供leading/trailing/top/bottom,方便subview布局

c、通过调整content view的size(可以是constraint的IBOutlet)来调整contentSize

d、不需要hard code与屏幕尺寸相关的代码

e、更好地支持rotation


UIScrollViewDelegate

UIScrollViewDelegate是UIScrollView的delegate protocol,UIScrollView的功能都是通过它的delegate方法实现的。了解这些方法被触发的条件及调用的顺序对于使用UIScrollView是很有必要的,这里主要讲拖动相关的效果,所以zoom相关的方法跳过不提。拖动相关的delegate方法按调用顺序分别是:

- (void)scrollViewDidScroll:(UIScrollView *)scrollView

这个方法在任何方式触发contentOffset变化的时候都会被调用(包括用户拖动,减速过程,直接通过代码设置等),可以用于监控contentOffset的变化,并根据当前的contentOffset对其他view做出随动调整。

- (void)scrollViewWillBeginDragging:(UISCrollView *)scrollView

用户开始拖动scroll view的时候被调用

- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset

该方法从iOS5引入,在didEndDragging前被调用,当willEndDragging方法中velocity为CGPointZero(结束拖动时两个方向都没有速度)时,didEndDragging中的de为NO,即没有减速过程,willBeginDecelerating和didEndDecelerating也不会被调用。反之,当velocity不为CGPointZero时,scrollView会以velocity为初始速度,减速直到targetContentOffset。值得注意的是,这里的targetContentOffset是个指针,没错,可以改变减速运动的目的地,这在一些效果的实现时十分有用,实例章节中会具体提到它的用法,并和其他实现方式作比较。

- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate

在用户结束拖动后被调用,decelerate为YES时,结束拖动后会有减速过程。,在didEndDragging之后,如果有减速过程,scroll view的dragging并不会立即置为NO,而是要等到减速结束之后,所以这个dragging属性的实际语义更接近scrolling。

- (void)scrollViewWillBeginDecelerating:(UIScrollView *)scrollView

减速动画开始前被调用。

- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView

减速动画结束时被调用,这里有一种特殊情况:当一次减速动画尚未结束的时候再次drag scrollView,didEndDecelerating不会被调用,并且这时scrollView的dragging和decelerating属性都是YES。新的dragging如果有加速度,那么willBeginDecelerating会再一次被调用,然后才是didEndDecelerating。如果没有加速度,虽然willBeginDecelerating不会被调用,但前一次留下的didEndDecelerating会被调用,所以连续快速滚动一个scroll view时,delegate方法被调用的顺序(不包含didScroll)可能是这样的:

a、scrollViewWillBeginDragging:

b、scrollViewWillEndDragging: withVelocity: targetContentOffset:

c、scrollViewDidEndDragging: willDecelerate:

d、scrollViewWillBeginDecelerating:

e、scrollViewWillBeginDragging:

f、scrollViewWillEndDragging: withVelocity: targetContentOffset:

g、scrollViewDidEndDragging: willDecelerate:

h、scrollViewWillBeginDecelerating:

……

scrollViewWillBeginDragging

scrollViewWillEndDragging: withVelocity: targetContentOffset:

scrollViewDidEndDragging: willDecelerate:

scrollViewWillBeginDecelerating:

scrollViewDidEndDecelerating:

虽然很少因为这个导致的bug,但是你需要知道这种很常见的用户操作会导致中间状态。例如你尝试在UITableViewDataSource的tableView: cellForRowAtIndexPath: 方法中基于tableView的dragging和decelerating属性判断是在用户拖拽还是减速过程中的话可能会误判(见例1)。

Sample中的Delegate简单输出了一些Log,你可以快速了解这些方法的调用shu顺序。


实例

下面通过一些实例,更详细地演示和描述以上各delegate方法的用途。

1、Table View中图片加载逻辑的优化

虽然这种优化方式在现在的机能和网路环境下可能看似不那么必要。

背景

当用户手动drag table view的时候,会加载cell中的图片;

在用户快速滑动的减速过程中,不加载过程中cell中的图片(但文字信息还是会被加载,只是减少减速过程中的网络开销和图片加载的开销);

在减速结束后,加载所有可见cell的图片(如果需要的话);

问题1:

前面提到,刚开始拖动的时候,dragging为YES,decelerating为NO;decelerate过程中,dragging和decelerating都为YES;decelerate未结束时开始下一次拖动,dragging和decelerating依然都为YES。所以无法简单通过table view的dragging和decelerating判断是在用户拖动还是减速过程。

解决这个问题很简单,添加一个变量如userDragging,在willBeginDragging中设为YES,didEndDragging中设为NO。那么tableView: cellForRowAtIndexPath: 方法中,是否load图片的逻辑就是:

if (!self.userDragging && tableView.decelerating) {

cell.imageView.image = nil;

} else {

// code for loading image from network or disk

问题2:

这么做的话,decelerate结束后,屏幕上的cell都是不带图片的,解决这个问题也不难,你需要一个形如loadImageForVisibleCells的方法,加载可见cell的图片:

- (void)loadImageForVisibleCells{

    NSArray *cells = [self.tableView visibleCells];

    for(GLImageCell *cell in cells){

        NSIndexPath *indexPath = [self.tableView indexPathForCell:Cell];

        [self setupCell:cell withIndexPath:indexPath];

    }

}

问题3:

这个问题可能不容易被发现,在减速过程中如果用户开始新的拖动,当前屏幕的cell并不会被加载(前边提到的调用顺序问题导致),而且问题1的解决方案不能解决问题3,因为这些cell已经在屏幕上,不会再次经过cellForRowAtIndexPath方法。虽然不容易发现,但解决很简单,只需要在scrollViewWillBeginDragging: 方法里面调用一次loadImageForVisibleCells即可。

上述方法在以前的确提升了table view的performance,但是你会发现在减速过程最后最慢的那零点几秒时间,其实还是会让人等的有些心急,尤其如果你的App只有图片没有文字。在iOS5引入了scrollViewWillEndDragging:withVelocity:targetContentOffset:方法后,配合SDWebImage,尝试优化了一下这个方法以提升用户体验:

a、如果内存中有图片的缓存,减速过程中也会加载该图片

b、如果图片属于targetContentOffset能看到的cell,正常加载,这样一来,快速滚动的最后一屏出来的过程中,用户就能看到目标区域的图片逐渐加载。

c、可以尝试用类似fade in或者flip的效果缓解生硬的突然出现

核心代码:

- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView

{

    self.targetRect = nil;

    [self loadImageForVisibleCells];

} 

- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset

{

   CGRect targetRect = CGRectMake(targetContentOffset->x, targetContentOffset->y, scrollView.frame.size.width, scrollView.frame.size.height);

   self.targetRect = [NSValue valueWithCGRect:targetRect];

}

- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView

{

   self.targetRect = nil;

   [self loadImageForVisibleCells];

}

是否需要加载图片的逻辑:

BOOL shouldLoadImage = YES;

if (self.targetRect && !CGRectIntersectsRect([self.targetRect CGRectValue], cellFrame)) {

    SDImageCache *cache = [manager imageCache];

    NSString *key = [manager cacheKeyForURL:targetURL];

    if (![cache imageFromMemoryCacheForKey:key]) {

        shouldLoadImage = NO;

    }

}

if (shouldLoadImage) {

// load image

}

更值得一提的是,通过判断是否为nil,targetRect同时起到了原来userDragging的作用。


分页的几种实现方式

利用UIScrollView有多种方法实现分页,但是各自的效果和用途不尽相同,其中方法2和方法3的区别也正是一些同类App在模仿Glow的首页Bubble翻转效果时跟Glow体验上的差距所在。这里通过三种方法实现类似的一个场景。为了区分每个例子的重点,本例没有重用机制,重用相关内容见例子3。

2.1 pagingEnabled

这是系统提供的分页方式,最简单,但是有一些局限性:

只能以frame size为单位翻页,减速动画阻尼大,减速过程不超过一页。需要一些hacking实现bleeding和padding(即页与页之间有padding,在当前页可以看到前后页的部分内容)。

Sample中Pagination有简单实现bleeding和padding效果的代码,主要的思路是:让scrollView的宽度为page宽度+padding,并且设置clipsToBounds为NO。

这样虽然能看到前后页的内容,但是无法响应touch,所以需要另一个覆盖期望的可触摸区域的View来实现类似touch bridging的功能。

使用场景:上述局限性同时也是这种实现方式的优点,比如一般App的引导页,Calendar里的月视图,都可以用这种方式实现。

2.2 Snap

这种方法就是在didEndDragging且无减速动画,或在减速动画完成时,snap到一个整数页。核心算法是通过当前contentOffset计算最近的整数页及其对应的contentOffset,通过动画snap到该页。这个实现方法的效果都有个通病,就是最后的snap会在decelerate结束后才放生,总感觉很突兀。

2.3修改targetContentOffset

通过修改scrollViewEndDragging: withVelocity: targetContentOffset: 方法中targetContentOffset直接修改目标offset为整数页位置。其中核心代码:

- (CGPoint)nearestTargetOffsetForOffset:(CGPoint)offset{


   CGFloat pageSize = BUBBLE_DIAMETER + BUBBLE_PADDING;

   NSInteger page = roundf(offset.x / pageSize);

   CGFloat targetX = pageSize * page;

   return CGPointMake(targetX, offset.y);

}

- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset{

 

    CGPoint targetOffset = [self nearestTargetOffsetForOffset:*targetContentOffset];

    targetContentOffset->x = targetOffset.x;

    targetContentOffset->y = targetOffset.y;

}

使用场景:方法2和方法3的原理近似,效果也近似,使用场景也基本相同,但方法3的体验会好些,snap到整数页的过程很自然,或者说用户完全感知不到snap过程的存在。这两种方法的减速过程流畅,使用于一屏有多页,但需要按整数页滑动的场景;也适用于每页大小不同的情况下snap到整数页的场景。


重用

大部分的iOS开发应该都清楚UITableView的cell重用机制,这种重用机制减少了内存开销也提高了performance,UIScrollView作为UITableView的父类,在很多场景中也很适合应用重用机制(其实不只是UIScrollView,任何场景中会反复出现的元素都应该适当地引入重用机制)。

参照UITableView的cell重用机制,总结重用机制如下:

a、维护一个重用队列

b、当需要加入新的元素时,先尝试从重用队列获取可重用元素(dequeue)并且从重用队列移除

c、如果队列为空,新建元素

这些一般都在scrollViewDidScroll:方法中完成


实际使用中,需要注意的点是:

a、当重用对象为view controller时,记得addChildViewController

b、当view或view controller被重用但其对应model发生变化的时候,需要及时清理重用前留下的内容

c、数据可以适当做缓存,在重用的时候尝试从缓存中读取数据甚至之前的状态(如tableview的contentOffset),以得到更好的用户体验。

d、当on screen的元素数量可确定的时候,有时候可以提前init这些元素,不会在scroll过程共遇到因为init开销带来的卡顿(尤其是以view controller为重用对象的时候)


联动/视差滚动

所谓联动,就是当A滚动的时候,在scrollViewDidScroll:里根据A的contentOffset动态计算B的contentOffset并设给B。同样对于非scrollview的C,也可以动态计算C的frame或是transform实现视差滚动或者其他高级动画,这在现在许多应用中的引导页面里会被用到。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值