前阵子在实现视差动画的时候,无意间看到了 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轴向下。
请注意,逻辑上所说的 coordinate system 并不关心他自己的宽和高。他是在四个方向上无限延伸的。(PS: 译者添加-也就是说在四个方向上可以无限给当前 View 添加 subView 来增添内容). 让我们在这个 coordinate system 中添加一些 subviews 来检验一下结果。下图中,每一个带颜色的块代表一个 subview:
代码如下所示:
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 矩形外面的部分仍旧在那里布局着,只不过我们看不到而已。
Frame
接下来,我们改变 bounds 矩形的原点试试:
CGRect bounds = mainView.bounds;
bounds.origin = CGPointMake(0, 100);
mainView.bounds = bounds;
bounds 矩形的原点变成了 (0,100),所以显示效果如下:
看起来视图向下移动了 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 的原点,就相当于移动这个透明胶片,结果就是这个胶片上的其他内容从不可见,到可以通过这个视窗看到了:
好了,这就是 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 是一个没有超出滑动范围的有效值。
最终实现结果如下:
总结
感谢 UIKit 中内置的 coordinate system,让我们用不到30行代码实现了 UIScrollView
的基本原理。当然,对于真正的 UIScrollView
来说,还有很多其他特性,比如 带有惯性的 scrolling,反弹特性,滑动指示标,放大缩小,还有那些我们没有实现某个功能的代理方法。
2014年5月2日更新:整个实现代码在 available on GitHub
2014年5月8日更新:查看进阶的一些文章follow-up post来实现类似惯性滑动,弹性,摩擦停止等等特性。
为此,写了一个Demo,并且添加了惯性滑动,边界Bounce等特性,Github链接 AppleUIScrollView