原文:探究UIScrollView

翻译: Understanding UIScrollView


我是Mike Ash的一系列的文章Let?s Build … 的忠实粉丝,在文章中他通过从零开始重建来解释Cocoa功能是如何工作的。在这篇文章中,我尝试着做了一些简单的东西,通过几行简单的代码来实现一个小的滚动视图。

但首先,让我们来仔细看看在UIKit中的坐标系是如何工作。当然你可以随个人意愿跳过下一节,如果你只关心滚动视图的实现。

坐标系


每个视图都定义了自己的坐标系。它看起来像这样,x轴指向右边,y轴指向下方:

1.png

一个UIView的坐标系

注意,逻辑坐标系统本身并不关注视图的宽度和高度。它没有边界,可以在四个方向上无限延伸。现在让我们这个坐标系中布局了几个项目(又名子视图)。每个彩色矩形代表一个子视图:

2.png

在坐标系中添加子视图。在代码中,是这样设置的:

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];

边界


在UIView的文档中是这样描述有关bounds属性的:

边界矩形…描述了自己的坐标系中的视图的位置和大小。

一个视图可以被认为是一个窗口或窗口的矩形区域定义的平面坐标系。这个视图的边界表达了这个矩形的位置和大小。

来说我们的视图的边界矩形–320×480 points的宽度和高度,它的原点是默认的(0,0)。视图平面坐标系统变成一个视窗,显示整个平面上的一小部分。范围之外的一切都还在那里,只是隐藏而已:

3.png

视图提供了一个坐标系统定义的视窗。该视图边界矩形描述可见区域的位置和大小。

Frame


下一步,我们将修改边界矩形的原点:

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

当边界矩形的原点是现在的(0,100)时,我们看起来的场景就像这样:

4.png

修改边界矩形的原点,相当于移动视窗。

看起来好像向下移动了100 points,而且也好像真实的关联了自己的坐标系。屏幕上的视窗实际位置(或它的父视图,为了更准确)是保持固定的,然而,这是由它的Frame决定的,它其实并没有改变:

框架矩形…描述它在父坐标系统中视图的位置和大小。

由于视图位置(从自己的角度)是固定的,可以把平面坐标系想象为一块我们可以左右拖动透明的薄膜,并认为我们是通过一个固定的窗口来进行浏览的。

调整边界的原点等同于移动透明薄膜,这样薄膜的另一部分就会在视图中可见。

5.gif

修改边界矩形的原点是相当于往相反方向移动坐标系,而视图的位置保持固定,因为它的frame不会改变。

而这正是当UIScrollView滚动时做的。注意,从用户的角度看来好像视图的子视图被移动了,但就其视图坐标系统而言,它的位置(换言之,它们的frames)保持不变。

让我们来构建UIScrollView


滚动视图并不需要时时更新其子视图的坐标使其滚动。它所要做的就是调整其边界的原点。有了这些知识,实现一个非常简单的滚动视图就易如反掌了。我们建立了一个手势识别来检测用户的平移手势,并通过不断拖动展示视图的边界来响应于这个手势:

// 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

就像这个UIScrollView,我们的类中必须有一个contentSize属性,它必须从外部设定来定义滚动区域的范围。当我们调整边界时,要确保只允许有效的值出现。

效果:

6.png

我们自定义的滚动视图。请注意,它缺乏动力滚动,跳跃和滚动指示器。

结论


由于坐标系统已经封装嵌套在UIKit中,所以我们仅仅使用不到30行代码重现UIScrollView的精髓。当然,还有真实的UIScrollView决不仅仅只有这一点。动力滚动,跳跃,滚动指示,缩放和委托方法等等这些我们没有在这里实现其功能。

2014年5月2日更新:现在该代码可以在 GitHub 上找到。

  1. 并非真正的无限。坐标系的范围受限于CGFloat类型的大小(在32位系统上是32字节,在64位系统上是64字节),这在实践中其实已经足够的大了。

  2. 实际上,除非clipsToBounds = =YES(默认是NO),子视图边界矩形之外仍将是可见的。视图不会检测边界范围以外的触动。