深入理解 UIScrollView

前阵子在实现视差动画的时候,无意间看到了 Ole BegeMann 大神关于 UIScrollView 的文章,UnderStand UIScrollView,获益匪浅。不禁感叹如若当时初学 UIKit 时,就碰到这篇文章,对于新手来说,理解 bounds,contentSize,contentOffset 这些让人烦恼的属性一定简单很多。

​ 这篇文章简单易懂,对新手非常友好,遂决定对这篇文章进行翻译,给 Ole 大神发送了邮件👨‍💻,获取转发翻译授权。

申请授权邮件

下面的内容就是直接翻译自 Ole 大神的博客,如有翻译的不好的地方,请各位批评指正。🙏


​ 我是Mike Ash Let's Build 系列文章的忠实粉丝,在这个系列的文章中他通过从头开始创建某些框架或功能,从而解释这些 CoCoa 框架的工作原理。在这篇 Blog 中,我决定做一些和Mike Ash 类似的事情,通过一小段代码实现我的小小 scroll view。

​ 首先,让我们看一看 UIKit 中 coordinate systems 是怎样工作的。如果你只对 scroll view 的实现感兴趣的话,可以跳过下面这一段。

Coordinate Systems

​ 每一个 view 定义了他自己的 coordinate system。如下图所示,X轴向右,Y轴向下。

A UIView coordinate system.

​ 请注意,逻辑上所说的 coordinate system 并不关心他自己的宽和高。他是在四个方向上无限延伸的。(PS: 译者添加-也就是说在四个方向上可以无限给当前 View 添加 subView 来增添内容). 让我们在这个 coordinate system 中添加一些 subviews 来检验一下结果。下图中,每一个带颜色的块代表一个 subview:

Adding subviews to the coordinate system.

代码如下所示:

UIView *redView = [[UIView alloc] initWithFrame:CGRectMake(20, 20, 100, 100)];
redView.backgroundColor = [UIColor colorWithRed:0.815 green:0.007
    blue:0.105 alpha:1];

UIView *greenView = [[UIView alloc] initWithFrame:CGRectMake(150, 160, 150, 200)];
greenView.backgroundColor = [UIColor colorWithRed:0.494 green:0.827
    blue:0.129 alpha:1];

UIView *blueView = [[UIView alloc] initWithFrame:CGRectMake(40, 400, 200, 150)];
blueView.backgroundColor = [UIColor colorWithRed:0.29 green:0.564
    blue:0.886 alpha:1];

UIView *yellowView = [[UIView alloc] initWithFrame:CGRectMake(100, 600, 180, 150)];
yellowView.backgroundColor = [UIColor colorWithRed:0.972 green:0.905
    blue:0.109 alpha:1];

[mainView addSubview:redView];
[mainView addSubview:greenView];
[mainView addSubview:blueView];
[mainView addSubview:yellowView];

Bounds

UIView 的官方文档中对于 bounds 这个属性解释如下:

The bounds rectangle … describes the view’s location and size in its own coordinate system.

一个 view 可以被当做是一个 window 窗口或者 viewport 视窗的矩形区域在他自己的 coordinate system定义的平面中。并且这个 view 的 bounds 表示这个矩形的位置和尺寸大小。

​ view 的 bounds 矩形的宽和高是320*480,origin 原点默认是 (0,0)。这个 view 就可以看成是一个在当前 coordinate system 平面中的视窗,用来展示整个平面的一小部分而已。在 bounds 矩形外面的部分仍旧在那里布局着,只不过我们看不到而已。

A view provides a viewport into the plane defined by its coordinate system. The view’s bounds rectangle describe the position and size of the visible area.

Frame

​ 接下来,我们改变 bounds 矩形的原点试试:

CGRect bounds = mainView.bounds;
bounds.origin = CGPointMake(0, 100);
mainView.bounds = bounds;

​ bounds 矩形的原点变成了 (0,100),所以显示效果如下:

`Modifying the origin of the bounds rectangle is equivalent to moving the viewport.`

​ 看起来视图向下移动了 100 个点,诚然,对于他自己的 coordinate system来说确实如此。这个视图在屏幕上的的真正位置(确切来说,或者是是在他的父视图上)仍然没有变,这个位置是由他的 frame 属性来决定的,frame 本身没有变:

The frame rectangle … describes the view’s location and size in its superview’s coordinate system.

由于这个视图的位置是固定的(从他自己的角度来说),把 coordinate system 平面看成是一片我们可以随意拖动的,透明的胶片,把 view 看成是一个固定的窗口,我们可以通过这个窗口看到下面胶片上的内容。改变 bounds’s 的原点,就相当于移动这个透明胶片,结果就是这个胶片上的其他内容从不可见,到可以通过这个视窗看到了:

Modifying the origin of the bounds rectangle is equivalent to moving the coordinate system in the opposite direction while the view’s position remains fixed because its frame does not change.

好了,这就是 UIScrollView 滑动时的真正原理。我们需要注意是,从用户的角度来看,好像是 view 的 subviews 在移动,其实这些 subviews 对于这个视图的的坐标系来说,没有改变(换句话说,这些 subviews 的 frame 没有变化)。

Build UIScrollView

一个 scroll view 不需要在滚动的时候频繁地更新他 subview的坐标。他只是更改了他自己的 bounds,仅此而已。明白了这个原理之后,实现一个简易的的 scroll view 就非常容易了。我们给 view 添加一个追踪用户 pan 手势的识别器,随时手势的滑动,转换并且更新 view 的 bounds就好:

// CustomScrollView.h
@import UIKit;

@interface CustomScrollView : UIView

@property (nonatomic) CGSize contentSize;

@end

// CustomScrollView.m
#import "CustomScrollView.h"

@implementation CustomScrollView

- (id)initWithFrame:(CGRect)frame
{
    self = [super initWithFrame:frame];
    if (self == nil) {
        return nil;
    }
    UIPanGestureRecognizer *gestureRecognizer = [[UIPanGestureRecognizer alloc]
        initWithTarget:self action:@selector(handlePanGesture:)];
    [self addGestureRecognizer:gestureRecognizer];
    return self;
}

- (void)handlePanGesture:(UIPanGestureRecognizer *)gestureRecognizer
{
    CGPoint translation = [gestureRecognizer translationInView:self];
    CGRect bounds = self.bounds;

    // Translate the view's bounds, but do not permit values that would violate contentSize
    CGFloat newBoundsOriginX = bounds.origin.x - translation.x;
    CGFloat minBoundsOriginX = 0.0;
    CGFloat maxBoundsOriginX = self.contentSize.width - bounds.size.width;
    bounds.origin.x = fmax(minBoundsOriginX, fmin(newBoundsOriginX, maxBoundsOriginX));

    CGFloat newBoundsOriginY = bounds.origin.y - translation.y;
    CGFloat minBoundsOriginY = 0.0;
    CGFloat maxBoundsOriginY = self.contentSize.height - bounds.size.height;
    bounds.origin.y = fmax(minBoundsOriginY, fmin(newBoundsOriginY, maxBoundsOriginY));

    self.bounds = bounds;
    [gestureRecognizer setTranslation:CGPointZero inView:self];
}

@end

就像UIKit 中真正的 UIScollView 一样,我们自己构建的类也有一个 contentSize 属性用来从外部设置来定义滑动范围。当我们改变 bounds 的时候,我们需要保证这个 bounds 是一个没有超出滑动范围的有效值。

最终实现结果如下:

Our custom scroll view in action. Note that it lacks momentum scrolling, bouncing, and scroll indicators.

总结

感谢 UIKit 中内置的 coordinate system,让我们用不到30行代码实现了 UIScrollView 的基本原理。当然,对于真正的 UIScrollView 来说,还有很多其他特性,比如 带有惯性的 scrolling,反弹特性,滑动指示标,放大缩小,还有那些我们没有实现某个功能的代理方法。

2014年5月2日更新:整个实现代码在 available on GitHub

2014年5月8日更新:查看进阶的一些文章follow-up post来实现类似惯性滑动,弹性,摩擦停止等等特性。

为此,写了一个Demo,并且添加了惯性滑动,边界Bounce等特性,Github链接 AppleUIScrollView

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,835评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,598评论 1 295
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,569评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,159评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,533评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,710评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,923评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,674评论 0 203
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,421评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,622评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,115评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,428评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,114评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,097评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,875评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,753评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,649评论 2 271

推荐阅读更多精彩内容