译文如下
这可能难以置信,但 UIScrollView 与标准的 UIView 并没有太大的区别。当然, UIScrollView 有一些更多的方法,但这些方法实际上只是现有 UIView 属性的外观。因此,大部分对 UIScrollView 工作方式的理解都来自于对 UIView 的理解 - 特别是对两步视图渲染过程的详细了解。
光栅化 && 合成
渲染过程的第一部分被称为光栅化。光栅化就是接收一组绘图指令并生成图像。例如,UIButton 会绘制一个带有圆角矩形和居中标题的图像。这些图像并没有被绘制到屏幕上;相反,它们被它们的视图保留,用于下一步。
一旦每个视图都有了它的光栅化图像,这些图像就会被叠加在一起,生成一个屏幕大小的图像,这个步骤被称为合成。视图层次结构在合成过程中起着重要的作用:一个视图的图像是在其父视图的图像上合成的。然后,那个合成的图像又被叠加在超级父视图的图像上,依此类推。层次结构顶部的视图是窗口,它的合成图像(这是视图层次结构中每个图像的合成)就是用户看到的。
从概念上讲,这种将独立的图像层叠在一起生成最终的平面图像的想法应该是有意义的,特别是如果你之前使用过像Photoshop这样的工具。我们在这个问题中还有另一篇文章详细解释了像素是如何显示在屏幕上的。
现在,回想一下,每个视图都有一个 bounds 和 frame 这2个矩形。在布局界面时,我们处理的是视图的 frame 。这使我们能够定位和调整视图的大小。视图的 frame 和 bounds 通常具有相同的大小(尽管变换可以改变这一点),但它们的原点(origin)通常会有所不同。理解这两个矩形如何工作是理解 UIScrollView 如何工作的关键。
在光栅化步骤中,视图并不关心即将发生的合成步骤。也就是说,它并不关心它的 frame(将用于定位视图的图像)或它在视图层次结构中的位置(将决定它的合成顺序)。视图在这个时候关心的唯一事情就是绘制自己的内容。这个绘制发生在每个视图的drawRect:方法中。
在调用drawRect:之前,会为视图创建一个空白的图像,以便在其中绘制内容。这个图像的坐标系统是视图的bounds 矩形。对于几乎每个视图来说,bounds 矩形的原点是{0, 0}。因此,要在光栅化图像的左上角绘制东西,你需要在 bounds 的原点,即点{x:0, y:0}处绘制。要在图像的右下角绘制东西,你需要在点 {x:宽度, y:高度} 处绘制。如果你在视图的 bounds 之外绘制,那么这个绘制不是光栅化图像的一部分,将被丢弃。
在组合步骤中,每个视图都会将其光栅化的图像叠加在其父视图的图像上(依此类推)。视图的 frame 矩形决定了视图的图像在其父视图的图像上的绘制位置 - frame 的原点指示了视图图像的左上角与其父视图图像之间的偏移量。因此,一个 frame 原点为 {x:20, y:15} 的视图将创建一个组合图像,其中视图的图像绘制在其父视图的图像上,向右偏移20点,向下偏移15点。由于视图的 frame 和 bounds 矩形总是相同的大小,因此图像是按像素对其父视图的图像进行组合的。这确保了光栅化的图像不会被拉伸或缩小。
记住,我们只是在讨论一个视图和其父视图之间的单一组合操作。一旦这两个视图被组合在一起,得到的组合图像就会与超级父视图的图像进行组合,依此类推:这就是雪球效应。
思考一下将一张图像组合到另一张图像上的数学原理。视图图像的左上角依据其自身 frame 的原点偏移,然后绘制到其父视图的图像上:
CompositedPosition.x = View.frame.origin.x - Superview.bounds.origin.x;
CompositedPosition.y = View.frame.origin.y - Superview.bounds.origin.y;
现在,正如我们之前所说,视图的 bounds 矩形的原点通常就是{0, 0}。因此,在做数学计算时,我们只需去掉其中一个值,我们就得到:
CompositedPosition.x = View.frame.origin.x;
CompositedPosition.y = View.frame.origin.y;
所以,我们可以看一下几种不同的 frame ,看看它们会是什么样子:
这应该是有道理的。我们改变按钮的 frame 原点,它就会改变相对于其美丽的紫色父视图的位置。注意,如果我们移动按钮,使其部分超出紫色父视图的 bounds ,这些部分会被剪裁,就像在光栅化期间的绘制会被剪裁一样。然而,从技术上讲,由于iOS在底层处理组合的方式,你可以让一个子视图在其父视图的 bounds 之外渲染,但是在光栅化期间的绘制不能在视图的 bounds 之外进行。
ScrollView 的 contentOffset
那么,所有这些与 UIScrollView 有什么关系呢?一切都有关系。想想我们可以实现滚动的一种方式:我们可以有一个视图,当我们拖动它时,我们改变它的 frame 。这实现了同样的事情,对吧?如果我把手指向右拖,我增加了我正在拖动的视图的frame.origin.x,瞧, ScrollView !
当然,问题在于, ScrollView 中通常有很多子视图。要实现这个平移功能,你每次用户移动手指时,都必须改变每个子视图的 frame 。但我们还遗漏了一些东西。还记得我们提出的那个公式,用来确定视图在其父视图上组合图像的位置吗?
CompositedPosition.x = View.frame.origin.x - Superview.bounds.origin.x;
CompositedPosition.y = View.frame.origin.y - Superview.bounds.origin.y;
我们去掉了Superview.bounds.origin的值,因为它们总是0。但是如果它们不是 0 呢?比如说,我们使用了前面图表中的相同 frame ,但我们将紫色视图的 bounds 原点改变为像{-30, -30}这样的值。我们会得到这样的结果:
现在,这个美妙之处在于,这个紫色视图的每一个子视图都被其 bounds 的改变所移动。事实上,当你设置其contentOffset 属性时,这就是 scrollView 的工作方式:它改变了滚动视图 bounds 的原点。实际上,contentOffset 甚至不是真实的!它的代码可能看起来像这样:
- (void)setContentOffset:(CGPoint)offset
{
CGRect bounds = [self bounds];
bounds.origin = offset;
[self setBounds:bounds];
}
注意,在前面的图表中,足够改变 bounds 的原点会将按钮移出紫色视图和按钮产生的合成图像之外。当你滚动一个滚动视图足够多,以至于一个视图消失时,就会发生这种情况!
ScrollView 的 contentSize
现在难点已经过去,让我们来看看 ScrollView 的另一个属性 contentSize 。
滚动视图的 contentSize 并不改变滚动视图的 bounds ,因此不影响滚动视图如何合成其子视图。相反, contentSize 定义了可滚动区域。默认情况下,滚动视图的 contentSize 是一个大大的{w:0, h:0}。由于没有可滚动区域,用户无法滚动,但滚动视图仍然会显示所有适合在滚动视图的 bounds 内的子视图。
当 contentSize 设置为大于滚动视图的 bounds 时,用户被允许滚动。你可以把滚动视图的 bounds 想象为一个窗口,进入由 contentSize 定义的可滚动区域:
当 contentOffset 为 {x:0, y:0} 时,视图窗口的左上角位于可滚动区域的左上角。这也是contentOffset 的最小值;用户无法向左或向上滚动超出可滚动区域。那里什么都没有!
contentOffset 的最大值是 contentSize 和滚动视图 bounds 大小的差值。这是有道理的;滚动到右下角,用户会被停止,以便滚动区域的右下边缘与滚动视图 bounds 的右下边缘齐平。你可以这样写出最大的contentOffset:
contentOffset.x = contentSize.width - bounds.size.width;
contentOffset.y = contentSize.height - bounds.size.height;
ScrollView 的 contentInset
contentInset 属性可以改变 contentOffset 的最大和最小值,允许在可滚动区域外滚动。 它的类型是UIEdgeInsets,由4个数字组成:{top, left, bottom, right}。当你使用一个 inset 时,你改变了 contentOffset 的范围。例如,将 contentInset 的 top 值设置为10,允许 contentOffset 的y值达到-10。这在可滚动区域周围引入了 padding
这一开始可能看起来不是很有用。实际上,为什么不直接增加 contentSize 呢?嗯,你应该避免改变滚动视图的 contentSize ,除非你必须这样做。为了理解为什么,考虑一下 UITableView(UITableView 是 UIScrollView 的子类,所以它具有所有相同的属性)。UITableView 的可滚动区域已经精心计算,以便每个单元格都能紧密地适应。当你滚动超过 UITableView 的第一个或最后一个单元格的边界时,UITableView 会将contentOffset弹回原位,使得单元格再次紧密地适应在滚动视图的bounds内。
现在,当你想要使用 UIRefreshControl 实现下拉刷新时会发生什么呢?你不能将 UIRefreshControl 放在 UITableView 的可滚动区域内,否则, UITableView 会允许用户在刷新控制的中途停止滚动,顶部会对齐到 UIRefreshControl 的顶部。因此,你必须将 UIRefreshControl 放在可滚动区域的上方,这允许内容偏移回到第一行,而不是刷新 UIRefreshControl 。
但是等一下,如果你通过滚动足够远来启动下拉刷新机制, UITableView 确实允许 contentOffset 将 UIRefreshControl 对齐到可滚动区域,这是因为 UITableView 的 contentInset 。当刷新动作启动时, contentInset 会被调整,以便最小 contentOffset 也能包括 UIRefreshControl 的全部。当刷新完成时, contentInset 会恢复正常, contentOffset 也会跟随恢复,确定 contentSize 所需的数学计算无需重新计算。
你如何在自己的代码中使用 contentInset 呢?它有一个很好的用途:当键盘出现在屏幕上时。通常,你会尝试设计一个紧贴屏幕的用户界面。当键盘出现在屏幕上时,你会失去几百像素的空间。键盘下面的所有东西都被遮住了。
现在,滚动视图的 bounds 没有改变, contentSize 也没有改变(也不应该改变)。但是用户不能滚动滚动视图。想想之前的等式:最大的 contenOffset 是 contentSize 和 bounds 的差值。如果它们相等(你的紧贴界面现在被一个键盘搞乱了你的日子),那么最大contentOffset就变成了{x:0, y:0}。
那么,技巧就是将界面放在一个滚动视图中。滚动视图的 contentSize 保持固定,与滚动视图的 bounds 大小相同。当键盘出现在屏幕上时,你将 contentInset 的 bottom 设置为键盘的高度。
这允许 contentOffset 的最大值显示超出可滚动区域的区域。可见区域的顶部在滚动视图的 bounds 之外,因此被剪裁(尽管它也在屏幕本身之外,所以这并不太重要)。