UIScrollView的详细使用介绍和实现原理分析[2018.06.20更新]

UIScrollView.png

概述

UIScrollView(滚动视图)是一个在日常开发中使用频率极高的容器视图控件, 它允许用户通过滚动和缩放的方式查看超出屏幕区域大小的内容, 在应用程序开发中经常使用到的UITableView(列表视图)、UICollectionView(集合视图)和UITextView(文本视图)都是它的子类.

下面将从用户界面和事件处理两个方面对UIScrollView做一次详细的使用介绍和简要的实现原理分析.

用户界面相关

内容区域相关API介绍

该属性用于标识内容区域的起点相对于scrollView的起点的偏移量, 默认值为CGPointZero

@property(nonatomic) CGPoint contentOffset;

- (void)setContentOffset:(CGPoint)contentOffset animated:(BOOL)animated;

该属性用于标识内容区域的尺寸, 默认值为CGSizeZero

@property(nonatomic) CGSize contentSize;

该属性用于标识为内容区域周围增加的可滚动区域, 默认值为UIEdgeInsetsZero

@property(nonatomic) UIEdgeInsets contentInset;

该属性用于标识为内容区域周围增加的总的可滚动区域, 该属性值的最终结果取决于contentInsetAdjustmentBehavior属性的值

@property(nonatomic, readonly) UIEdgeInsets adjustedContentInset API_AVAILABLE(ios(11.0));

- (void)adjustedContentInsetDidChange API_AVAILABLE(ios(11.0)) NS_REQUIRES_SUPER;

该属性用于配置safeAreaInsets如何影响adjustedContentInset属性的值, 该属性可设置四个枚举值:

  • UIScrollViewContentInsetAdjustmentAutomatic: 默认, 在UIScrollViewContentInsetAdjustmentScrollableAxes的基础上添加了向前兼容. 不论是否可以滚动, 如果scrollView所在的控制器位于导航控制器中且automaticallyAdjustsScrollViewInsets = YES, 则在上下两个方向上adjustedContentInset = contentInset + safeAreaInsets成立
  • UIScrollViewContentInsetAdjustmentScrollableAxes: 在可滚动方向上adjustedContentInset = contentInset + safeAreaInsets成立. 比如: contentSize.width/height > frame.size.width/height或者alwaysBounceHorizontal/Vertical = YES
  • UIScrollViewContentInsetAdjustmentNever: 在任何情况下adjustedContentInset = contentInset成立
  • UIScrollViewContentInsetAdjustmentAlways: 在任何情况下adjustedContentInset = contentInset + safeAreaInsets成立
@property(nonatomic) UIScrollViewContentInsetAdjustmentBehavior contentInsetAdjustmentBehavior API_AVAILABLE(ios(11.0));

该属性用于标识内容区域和scrollViewAuto Layout参考线

@property(nonatomic,readonly,strong) UILayoutGuide *contentLayoutGuide API_AVAILABLE(ios(11.0));

@property(nonatomic,readonly,strong) UILayoutGuide *frameLayoutGuide API_AVAILABLE(ios(11.0));
指示器相关API介绍

该属性用于配置指示器样式, 该属性可设置三个枚举值:

  • UIScrollViewIndicatorStyleDefault: 默认, 黑内容白边框, 适用于任何背景
  • UIScrollViewIndicatorStyleBlack: 全黑, 较小, 适用于白色背景
  • UIScrollViewIndicatorStyleWhite: 全白, 较小, 适用于黑色背景
@property(nonatomic) UIScrollViewIndicatorStyle indicatorStyle;

该属性用于标识为指示器周围增加的可滚动区域, 默认值为UIEdgeInsetsZero

@property(nonatomic) UIEdgeInsets scrollIndicatorInsets;

该属性用于标识是否在滚动时指示器可见, 默认为值YES

@property(nonatomic) BOOL showsHorizontalScrollIndicator;
@property(nonatomic) BOOL showsVerticalScrollIndicator;

该方法用于闪动一下指示器. 建议在将scrollView展示给用户时调用一下, 以提醒用户该控件可以滚动

- (void)flashScrollIndicators;
滚动相关API介绍

该属性用于标识是否允许滚动, 默认值为YES

@property(nonatomic,getter=isScrollEnabled) BOOL scrollEnabled;

该属性用于标识是否只允许同时滚动一个方向, 默认值为NO. 如果设置为YES, 则用户在水平/竖直方向上开始进行滚动操作, 便禁止同时在竖直/水平方向上进行滚动

注: 当用户在对角线方向上开始进行滚动操作, 则本次滚动可以同时在两个方向上进行滚动

@property(nonatomic, getter=isDirectionalLockEnabled) BOOL directionalLockEnabled;

该属性用于标识是否允许通过点击状态栏让距离状态栏最近的scrollView滚动到顶部, 默认值为YES

注: 当同时存在多个将该属性设置为YESscrollView, 则该属性在iPhone中无效; 在iPad中将距离状态栏最近的scrollView滚动到顶部

@property(nonatomic) BOOL scrollsToTop;

该属性用于标识是否按页数进行滚动, 默认值为NO. 如果设置为YES, 则在滚动时只会停止在scrollViewbounds的整数倍处

@property(nonatomic, getter=isPagingEnabled) BOOL pagingEnabled;

该属性用于标识是否有触底反弹效果, 默认值为YES

@property(nonatomic) BOOL bounces;

该属性用于标识是否总是有触底反弹效果(即使contentSize小于scrollView的尺寸), 默认值为NO

注: 该属性生效的前提条件为bounces = YES

@property(nonatomic) BOOL alwaysBounceHorizontal;
@property(nonatomic) BOOL alwaysBounceVertical;

该属性用于配置当用户手指离开屏幕后滚动减速的速率, 该属性可设置两个常量:

  • UIScrollViewDecelerationRateNormal: 默认, 慢慢停止
  • UIScrollViewDecelerationRateFast: 快速停止
@property(nonatomic) CGFloat decelerationRate NS_AVAILABLE_IOS(3_0);

该方法用于将指定区域滚动到刚好可见处

- (void)scrollRectToVisible:(CGRect)rect animated:(BOOL)animated;
缩放相关API介绍

该属性用于标识最小缩放比例, 默认值为1.0

@property(nonatomic) CGFloat minimumZoomScale;

该属性用于标识最大缩放比例, 默认值为1.0

注: 该属性值必须大于minimumZoomScale才能进行缩放

@property(nonatomic) CGFloat maximumZoomScale;

该属性用于标识缩放比例, 默认值为1.0

@property(nonatomic) CGFloat zoomScale NS_AVAILABLE_IOS(3_0);

- (void)setZoomScale:(CGFloat)scale animated:(BOOL)animated NS_AVAILABLE_IOS(3_0);

该方法用于将内容缩放到指定区域

- (void)zoomToRect:(CGRect)rect animated:(BOOL)animated NS_AVAILABLE_IOS(3_0);

该属性用于标识是否允许触底反弹, 默认值为YES

@property(nonatomic) BOOL bouncesZoom;

该属性用于标识是否正在缩放

@property(nonatomic,readonly,getter=isZooming) BOOL zooming;

该属性用于标识是否正在触底反弹

@property(nonatomic,readonly,getter=isZoomBouncing) BOOL zoomBouncing;

用户界面实现原理

framebounds

这部分内容将会简单介绍一下UIView的两个属性: framebounds, 这将有助于理解UIScrollView用户界面的实现原理.

iOS系统中, 视图的坐标系统的原点默认位于视图的左上角, 右方向为x轴的正方向, 下方向为y轴的正方向. 其中, frame用于描述视图在父视图坐标系统中的位置和尺寸; bounds用于描述视图在自身坐标系统中的位置和尺寸. 下面通过两个代码片段来具体说明:

// 代码片段1
UIView *superView = [[UIView alloc] initWithFrame:CGRectMake(20.f, 20.f, 100.f, 100.f)];
superView.backgroundColor = [UIColor redColor];
[self.view addSubview:superView];

UIView *subView = [[UIView alloc] initWithFrame:CGRectMake(20.f, 20.f, 60.f, 60.f)];
subView.backgroundColor = [UIColor yellowColor];
[superView addSubview:subView];

NSLog(@"superView.frame = %@, superView.bounds = %@", NSStringFromCGRect(superView.frame), NSStringFromCGRect(superView.bounds));
// 输出: superView.frame = {{20, 20}, {100, 100}}, superView.bounds = {{0, 0}, {100, 100}}
NSLog(@"subView.frame = %@, subView.bounds = %@", NSStringFromCGRect(subView.frame), NSStringFromCGRect(subView.bounds));
// 输出: subView.frame = {{20, 20}, {60, 60}}, subView.bounds = {{0, 0}, {60, 60}}
父视图坐标系统
// 代码片段2
UIView *superView = [[UIView alloc] initWithFrame:CGRectMake(20.f, 20.f, 100.f, 100.f)];
superView.backgroundColor = [UIColor redColor];
[self.view addSubview:superView];

UIView *subView = [[UIView alloc] initWithFrame:CGRectMake(20.f, 20.f, 60.f, 60.f)];
subView.backgroundColor = [UIColor yellowColor];
[superView addSubview:subView];

// 新增代码
superView.bounds = CGRectMake(0, 20, 100, 100);

NSLog(@"superView.frame = %@, superView.bounds = %@", NSStringFromCGRect(superView.frame), NSStringFromCGRect(superView.bounds));
// 输出: superView.frame = {{20, 20}, {100, 100}}, superView.bounds = {{0, 20}, {100, 100}}
NSLog(@"subView.frame = %@, subView.bounds = %@", NSStringFromCGRect(subView.frame), NSStringFromCGRect(subView.bounds));
// 输出: subView.frame = {{20, 20}, {60, 60}}, subView.bounds = {{0, 0}, {60, 60}}
自身坐标系统

通过以上两个代码片段可以看出, superViewbounds.origin发生变化并不影响其自身所处的位置, 但是却会影响到subView的位置. 这是因为superViewbounds.origin发生变化直接导致了自身坐标系统的原点发生了改变, 即通过bounds.origin设置的值便是superView的左上角在自身坐标系统中的位置, 而superView则会根据自身新的坐标系统更新其subView的位置.

注: 本文在此仅涉及bounds属性的变化对位置的影响, 如果想了解其对尺寸的影响烦请自行Google.

实现原理

通过上一部分内容的介绍, 理解UIScrollView用户界面的实现原理将不再有困难. 其实UIScrollView只是在用户滚动的时候动态修改其bounds.origin的值, 这样便会相应地影响子视图的位置变化, 而其他滑动相关属性则均用于约束bounds.origin的变化范围. 以常用的四个属性为例:

  • contentOffset: 当用户在scrollView中向上滑动时, 设置bounds.origin的值逐渐增加, 此时所有的子视图便会相应地向上移动. 其实contentOffset = bounds.origin.
  • contentSize: 由于bounds.origin的值可以随意变化, 因此scrollView便可以无限制地向四周滚动. 其实contentSize的值便是可滚动范围的抽象.
  • contentInsetadjustedContentInset: 在不改变contentSize的前提下对可滚动范围进行扩展.
iOS11中的新变化

iOS10及以前, 当scrollView所在的控制器位于导航控制器的最顶层时, 系统会通过contentInset属性自动为scrollView上方增加64pt的可滚动区域以防内容区域被导航栏遮挡. 该种优化方式可以通过设置控制器的automaticallyAdjustsScrollViewInsets = NO来禁用.

注: 系统只在UIScrollView是控制器视图的第0个子视图时才会自动修改其contentInset属性和scrollIndicatorInsets属性

iOS11中, 上述优化方式被废弃. 系统通过adjustedContentInset属性配合contentInsetAdjustmentBehavior属性来处理scrollView的内容区域超出安全区域以外的情况, 这是一种对原有优化方式的升级, 避免了原有的一刀切的优化方式.

adjustedContentInset.png

注: 不要被图片误导, adjustedContentInset属性的值是包含contentInset属性的值的

事件处理相关

触摸相关API介绍

该属性用于标识用户是否已经触摸了内容区域并准备进行滑动

注: 该属性值被设置为YES的时候用户可能只是触摸了内容区域, 但是并没有开始进行滑动

@property(nonatomic,readonly,getter=isTracking) BOOL tracking;

该属性用于标识用户是否已经开始滑动内容区域

注: 该属性值被设置为YES之前用户可能需要先滑动一段时间或距离

@property(nonatomic,readonly,getter=isDragging) BOOL dragging;

该属性用于标识是否正在处于减速状态(即手指已经离开屏幕, 但scrollView仍然处于滑动中)

@property(nonatomic,readonly,getter=isDecelerating) BOOL decelerating;

该属性用于标识是否延迟内容区域的事件传递, 默认值为YES. 如果设置为NO, 则scrollView会立即调用-touchesShouldBegin:withEvent:inContentView:方法以进行下一步操作

@property(nonatomic) BOOL delaysContentTouches;

当已经将事件传递给子视图后是否可以取消, 默认值为YES. 如果设置为NO, 则一旦开始跟踪事件, 即使手指进行移动也不会取消已经传递给子视图的事件

@property(nonatomic) BOOL canCancelContentTouches;

该方法用于在UIScrollView的子类中重写, 返回是否将事件传递给对应的子视图, 默认返回YES. 如果返回NO, 则该事件不会传递给对应的子视图

- (BOOL)touchesShouldBegin:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event inContentView:(UIView *)view;

该方法用于在UIScrollView的子类中重写, 返回当已经将事件传递给子视图后是否可以取消. 默认当子视图是UIControl时返回NO, 即不再继续跟踪用户的触摸事件; 否则返回YES, 即仍然继续跟踪用户的触摸事件

注: 该方法被调用的前提是canCancelContentTouches = YES

- (BOOL)touchesShouldCancelInContentView:(UIView *)view;

其他相关API介绍

该属性用于配置隐藏键盘的模式, 该属性可设置三个枚举值:

  • UIScrollViewKeyboardDismissModeNone: 默认值, 不隐藏键盘
  • UIScrollViewKeyboardDismissModeOnDrag: 当拖拽时隐藏键盘
  • UIScrollViewKeyboardDismissModeInteractive: 当拖拽键盘上方时隐藏键盘, 如果反向拖拽键盘会取消隐藏
@property(nonatomic) UIScrollViewKeyboardDismissMode keyboardDismissMode NS_AVAILABLE_IOS(7_0);

该属性用于标识内建的拖动手势和捏合手势, 可在此对其进行配置

@property(nonatomic, readonly) UIPanGestureRecognizer *panGestureRecognizer NS_AVAILABLE_IOS(5_0);
@property(nonatomic, readonly) UIPinchGestureRecognizer *pinchGestureRecognizer NS_AVAILABLE_IOS(5_0);

该属性用于标识内建的下拉刷新控件, 可在此实现下拉刷新功能

@property (nonatomic, strong, nullable) UIRefreshControl *refreshControl NS_AVAILABLE_IOS(10_0);

事件处理实现原理

由于scrollView并没有用于直接操控的滚动条, 因此用户只能通过直接操作scrollView的内容区域以便进行滚动操作. 但是当用户触碰到屏幕上时, scrollView并不清楚该用户的目的是想要进行滚动操作还是单纯地想要点击某一个视图. 为了处理这种情况, 当用户触碰屏幕时, scrollView首先拦截到该触摸事件并启用一个150s的定时器, 同时观察用户的下一步行为.

  • 当定时器结束前, 如果用户的触摸点发生足够的移动, 则直接滚动内容区域, 并且不会继续将该触摸事件传递给子视图.
  • 当定时器结束后, 如果用户的触摸点并没有发生足够的移动, 则调用-touchesShouldBegin:withEvent:inContentView:方法询问是否将事件传递给对应的子视图. 如果返回NO, 则该事件不会传递给对应的子视图; 如果返回YES, 则该事件会传递给对应的子视图, 默认为YES.
  • 当触摸事件被传递给子视图后, 如果canCancelContentTouches=YES, 则会立即调用-touchesShouldCancelInContentView:方法询问是否可以取消已经传递给子视图的事件. 如果返回NO, 则不再进一步跟踪用户的触摸事件; 如果返回YES, 则当用户的触摸点又发生足够的移动时, 系统会向该子视图发送-touchesCancelled:withEvent:消息并进行滑动.

代理相关

该方法在contentOffset发生变化时调用

- (void)scrollViewDidScroll:(UIScrollView *)scrollView;

该方法在将要开始拖拽时调用

注: 该方法可能需要先滑动一段时间或距离才会被调用

- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView;

该方法在用户停止拖拽时调用

注: 应用程序可以通过修改targetContentOffset参数的值来调整停止的位置

- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset NS_AVAILABLE_IOS(5_0);

该方法在用户停止拖拽时调用

注: 如果在停止拖拽后继续移动, 则decelerate参数为YES

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

该方法在将要开始减速时调用

注: 仅当停止拖拽后继续移动时才会被调用

- (void)scrollViewWillBeginDecelerating:(UIScrollView *)scrollView;

该方法在已经结束减速时调用

注: 仅当停止拖拽后继续移动时才会被调用

- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView;

该方法用于返回是否允许点击状态栏让scrollView滑动到顶部, 默认值为YES

注: 仅当scrollsToTop属性值为YES时才调用

- (BOOL)scrollViewShouldScrollToTop:(UIScrollView *)scrollView;

该方法在scrollView已经滑动到顶部时调用

注: 仅当通过点击状态栏让scrollView滑动到顶部才调用

- (void)scrollViewDidScrollToTop:(UIScrollView *)scrollView;

该方法在-setContentOffset:animated:/-scrollRectVisible:animated:方法动画结束时调用

注: 仅当animated设置为YES时才调用

- (void)scrollViewDidEndScrollingAnimation:(UIScrollView *)scrollView;

该方法在缩放比例发生变化时调用

- (void)scrollViewDidZoom:(UIScrollView *)scrollView NS_AVAILABLE_IOS(3_2);

该方法用于返回参与缩放的子视图

- (UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView;

该方法在将要开始缩放时调用

- (void)scrollViewWillBeginZooming:(UIScrollView *)scrollView withView:(UIView *)view NS_AVAILABLE_IOS(3_2);

该方法在已经结束缩放时调用

- (void)scrollViewDidEndZooming:(UIScrollView *)scrollView withView:(UIView *)view atScale:(CGFloat)scale;

该方法在adjustedContentInset发生变化时调用

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

推荐阅读更多精彩内容