1.UIScrollView是什么?
移动设备的屏幕⼤小是极其有限的,因此直接展⽰在用户眼前的内容也相当有限,当展⽰的内容较多,超出一个屏幕时,在这种情况下,需要用户对屏幕内容进行拖动或缩放来查看屏幕或窗口区域外的内容。例如,在手机屏幕上显示内容丰富的网页或者很大的图片。而UIScrollView(滚动视图)是一个在日常开发中使用频率极高的容器视图控件, 它允许用户通过滚动和缩放的方式查看超出屏幕区域大小的内容, 在应用程序开发中经常使用到的UITableView(列表视图)、UICollectionView(集合视图)和UITextView(文本视图)都是它的子类.
2.UIScrollView实现原理
1>视图的坐标系统
每个视图都定义了自己的坐标系。看起来如下图所示,x轴指向右侧,y轴指向下方:
注意:这个坐标系并不关心视图的宽度和高度。它没有边界,在四个方向上无限延伸。现在让我们在这个坐标系中展示一些项目(又称子视图)。每个彩色矩形代表一个子视图:
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相当于UIViewController的根视图
[mainView addSubview:redView];
[mainView addSubview:greenView];
[mainView addSubview:blueView];
[mainView addSubview:yellowView];
2>视图的边界
UIView
的bounds
属性用于在视图自己的坐标系中描述视图的位置和大小。
一个视图可以被认为是一个窗口到由其坐标系定义的平面的矩形区域。view
的bounds
表示这个矩形的位置和大小。
我们假设一个视图的bounds矩形的宽度和高度为320×480点,其原点是默认的(0, 0)。视图成为坐标系平面的窗口,显示了整个平面的一小部分。界外的一切视图仍然存在,除非设置了隐藏或者clipsToBounds
为YES
(默认是NO),否则窗口矩形视图外的子视图将保持可见。尽管如此,该视图并不会检测边界之外的触摸点击事件。
3>视图的帧
接下来,我们修改修改边界矩形(UIViewController的根视图)的原点:
CGRect bounds = mainView.bounds;
bounds.origin = CGPointMake(0, 100);
mainView.bounds = bounds;
修改后,边界矩形的原点现在在(0, 100)的位置,场景如下所示:
看起来好像这个view
已经下降了100个pt(点),事实上这个view
相对于它自己的坐标系是正确的。视图在屏幕上的实际位置(或者在其父视图中)仍然是固定的,由边界矩形决定的frame
并没有改变,它的frame用于在其父视图的坐标系中描述子视图的位置和大小。
由于视图的位置是固定的(从它自己的角度来看),所以可以把坐标系平面看作是我们可以拖动的一片透明胶片,可以将边界矩形view
看作是我们正在查看的手机的固定窗口。调整bounds原点相当于移动透明薄膜,使其另一部分视图通过边界视图变成可见:
而这正是UIScrollView
的滚动原理。请注意,从用户的角度来看,尽管视图的子视图在视图的坐标系统(换言之,其帧)方面的位置保持不变,但看起来好像视图的子视图正在移动。
4.>尝试自己构建一个ScrollView
由上面的说明,我们可以知道滚动视图并不需要不断更新其子视图的坐标以使其滚动。它所要做的只是调整边界的起点。有了这些知识,我们就可以实现一个非常简单的滚动视图了。我们设置了一个手势识别器来检测用户的平移手势,为了响应手势,我们将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
就像UIScrollView一样,我们要有一个contentSize
属性,通过从外面设置这个属性来定义可滚动区域的范围。当我们调整边界时,我们要确保只允许有效值
运行效果:
这里我们只是实现的scrollView的滑动效果,官方的UIScrollView还涉及到动量滚动、弹跳、滚动指标、缩放和委托方法等功能,只是我们在这里没有实现。
3.UIScrollView的基本使用
// 创建UIScrollView
UIScrollView *myScrollView = [UIScrollView alloc] init];
// 创建UIImageView
UIImageView *imageView = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"image_01"]];
// 添加图片
[self.scrollView addSubview:imageView];
// 设置滚动范围
self.scrollView.contentSize = imageView.frame.size;
// 设置内边距(在原来内容的周边,添加内边距)
self.scrollView.contentInset = UIEdgeInsetsMake(10, 20, 40, 80);
4.UIScrollView属性介绍
显示内容区域相关的属性
@property(nonatomic) CGSize contentSize;
表示scrollView显示内容的宽高大小, 默认值为CGSizeZero,当设置的contentSize的Size大小大于scrollView的Size的时候才会滚动
@property(nonatomic) CGPoint contentOffset;
表示显示内容的左上角原点相对于scrollView的原点的偏移量, 默认值为CGPointZero,简单的说就是表示UIScrollView滚动的位置
@property(nonatomic) UIEdgeInsets contentInset;
表示scrollView的内边距,也就是内容视图边缘和scrollView的边缘的留空距离,默认值是UIEdgeInsetsZero,也就是没边距。这个属性能够在UIScrollView的四周增加额外的滚动区域,一般用来避免scrollView的内容被其他控件挡住.
在iOS10及以前, 当scrollView所在的控制器位于导航控制器的最顶层时, 系统会通过contentInset属性自动为scrollView上方增加64pt的可滚动区域以防内容区域被导航栏遮挡. 该种优化方式可以通过设置控制器的automaticallyAdjustsScrollViewInsets = NO来禁用.在iOS11中, 上述优化方式被废弃. 系统通过adjustedContentInset属性配合contentInsetAdjustmentBehavior属性来处理scrollView的内容区域超出安全区域以外的情况, 这是一种对原有优化方式的升级, 避免了原有的一刀切的优化方式.
具体作用如下图所示:
@property(nonatomic, readonly) UIEdgeInsets adjustedContentInset API_AVAILABLE(ios(11.0),tvos(11.0));
表示为内容区域周围增加的总的可滚动区域, 该属性值的最终结果取决于contentInsetAdjustmentBehavior属性的值
不要被图片误导, adjustedContentInset属性的值是包含contentInset属性的值的
@property(nonatomic) UIScrollViewContentInsetAdjustmentBehavior contentInsetAdjustmentBehavior API_AVAILABLE(ios(11.0),tvos(11.0));
该属性用于配置safeAreaInsets如何影响adjustedContentInset属性的值, 该属性可设置四个枚举值:
UIScrollViewContentInsetAdjustmentAutomatic
: 默认, 在UIScrollViewContentInsetAdjustmentScrollableAxes
的基础上添加了向前兼容. 不论是否可以滚动, 如果scrollView所在的控制器位于导航控制器中且automaticallyAdjustsScrollViewInsets = YES, 则在上下两个方向上的adjustedContentInset
= contentInset + safeAreaInsets.UIScrollViewContentInsetAdjustmentScrollableAxes
: 在可滚动方向上的adjustedContentInset
= contentInset + safeAreaInsets. 比如: contentSize.width/height > frame.size.width / height或者alwaysBounceHorizontal / Vertical = YESUIScrollViewContentInsetAdjustmentNever
: 在任何情况下adjustedContentInset
= contentInset.UIScrollViewContentInsetAdjustmentAlways
: 在任何情况下adjustedContentInset
= contentInset + safeAreaInsets.
@property(nonatomic,readonly,strong) UILayoutGuide *contentLayoutGuide API_AVAILABLE(ios(11.0),tvos(11.0));
@property(nonatomic,readonly,strong) UILayoutGuide *frameLayoutGuide API_AVAILABLE(ios(11.0),tvos(11.0));
这两个属性用于标识内容区域和scrollView的Auto Layout参考线
滚动相关的属性
@property(nonatomic,getter=isDirectionalLockEnabled) BOOL directionalLockEnabled;
设置是否只允许同时滚动一个方向,默认值为NO。设置为YES时,则用户在水平/竖直方向上开始进行滚动操作, 但禁止同时在竖直/水平方向上进行滚动。但是如果用户是首次在对角线方向上开始进行滚动操作, 则本次滚动可以同时在两个方向上进行滚动.
@property(nonatomic)BOOL bounces;
用于标识是否有触底反弹效果, 默认值为YES
//控制水平方向遇到边框是否反弹
@property(nonatomic) BOOL alwaysBounceHorizontal;
//控制垂直方向遇到边框是否反弹
@property(nonatomic) BOOL alwaysBounceVertical;
该属性用于标识是否总是有触底反弹效果(即使contentSize小于scrollView的尺寸), 默认值为NO,该属性生效的前提条件为bounces = YES
@property(nonatomic, getter=isPagingEnabled) BOOL pagingEnabled;
该属性用于标识是否按页数进行滚动, 默认值为NO. 如果设置为YES, 则在滚动时会按照在scrollView的bounds的整数倍对内容视图进行分页。
@property(nonatomic,getter=isScrollEnabled) BOOL scrollEnabled;
设置scrollview是否允许滚动, 默认值为YES
@property(nonatomic) CGFloat decelerationRate NS_AVAILABLE_IOS(3_0);
设置当用户手指离开屏幕后滚动减速的速率, 该属性可设置两个常量:
-
UIScrollViewDecelerationRateNormal
: 默认, 慢慢停止 -
UIScrollViewDecelerationRateFast
: 快速停止
@property(nonatomic) BOOL scrollsToTop;
该属性用于标识是否允许通过点击状态栏让距离状态栏最近的scrollView滚动到顶部, 默认值为YES, 当同时存在多个将该属性设置为YES的scrollView, 则该属性在iPhone中无效; 在iPad中将距离状态栏最近的scrollView滚动到顶部
状态条显示相关的属性
//设置UIScrollView是否显⽰示⽔水平滚动条
@property(nonatomic) BOOL showsHorizontalScrollIndicator;
//设置UIScrollView是否显⽰示⽔垂直滚动条
@property(nonatomic) BOOL showsVerticalScrollIndicator;
//用于标识滚动条增加的可滚动区域, 默认值为UIEdgeInsetsZero
@property(nonatomic)UIEdgeInsets scrollIndicatorInsets;
@property(nonatomic)UIScrollViewIndicatorStyle indicatorStyle;
用于设置滚动条的样式, 该属性可设置三个枚举值:
-
UIScrollViewIndicatorStyleDefault
: 默认, 黑内容白边框, 适用于任何背景 -
UIScrollViewIndicatorStyleBlack
: 全黑, 较小, 适用于白色背景 -
UIScrollViewIndicatorStyleWhite
: 全白, 较小, 适用于黑色背景
触摸相关的属性
@property(nonatomic,readonly,getter=isTracking) BOOL tracking;
只读,该属性用于标识用户是否已经触摸了内容区域并准备进行滑动,该属性值为YES的时候用户可能只是触摸了内容区域, 但是并没有开始进行滑动
@property(nonatomic,readonly,getter=isDragging) BOOL dragging;
只读,当用户开始拖动(手指已经在屏幕上滑动一段距离了),该属性值才为YES
@property(nonatomic,readonly,getter=isDecelerating) BOOL decelerating;
只读,该属性用于标识是否正在处于减速状态(即手指已经离开屏幕, 但scrollView仍然处于滑动中)
@property(nonatomic) BOOL delaysContentTouches;
该属性用于标识是否延迟触屏手势的事件传递, 默认值为YES. 如果设置为NO, 则scrollView会立即调用-touchesShouldBegin:withEvent:inContentView:方法以进行下一步操作
@property(nonatomic) BOOL canCancelContentTouches;
当已经将事件传递给子视图后是否可以取消, 默认值为YES. 如果设置为NO, 则一旦开始跟踪事件, 即使手指进行移动也不会取消已经传递给子视图的事件
缩放相关属性
//该属性用于标识最小缩放比例, 默认值为1.0
@property(nonatomic) CGFloat minimumZoomScale;
//该属性用于标识最大缩放比例, 默认值为1.0
@property(nonatomic) CGFloat maximumZoomScale;
maximumZoomScale
属性值必须大于minimumZoomScale
才能进行缩放
@property(nonatomic) CGFloat zoomScale NS_AVAILABLE_IOS(3_0);
当前的缩放比例。默认值为1.0,系统会根据缩放过程调整此值。
@property(nonatomic) BOOL bouncesZoom;
标识在缩放超过缩放比例时,是否反弹,默认值为YES。如果值为NO,则达到最大或最小缩放比例时会立即停止缩放。否则,产生弹簧效果。
@property(nonatomic,readonly,getter=isZooming) BOOL zooming;
只读,标识用户是否正在进行缩放手势
@property(nonatomic,readonly,getter=isZoomBouncing) BOOL zoomBouncing;
只读,当缩放超过最大或者最小范围的时候,回弹到最大最小范围的过程中,该值会返回YES。
手势相关的属性
@property(nonatomic, readonly) UIPanGestureRecognizer *panGestureRecognizer NS_AVAILABLE_IOS(5_0);
@property(nonatomic, readonly) UIPinchGestureRecognizer *pinchGestureRecognizer NS_AVAILABLE_IOS(5_0);
用于标识内建的拖动手势和捏合手势, 可在此对其进行配置
键盘相关属性
@property(nonatomic) UIScrollViewKeyboardDismissMode keyboardDismissMode NS_AVAILABLE_IOS(7_0);
该属性用于配置 当拖拽发生时,键盘的消失模式, 该属性可设置三个枚举值:
-
UIScrollViewKeyboardDismissModeNone
: 默认值, 不隐藏键盘 -
UIScrollViewKeyboardDismissModeOnDrag
: 当拖拽时隐藏键盘 -
UIScrollViewKeyboardDismissModeInteractive
: 当拖拽键盘上方时隐藏键盘, 如果反向拖拽键盘会取消隐藏
下拉刷新控件相关属性
@property (nonatomic, strong, nullable) UIRefreshControl *refreshControl NS_AVAILABLE_IOS(10_0);
该属性用于标识内建的下拉刷新控件, 可在此实现下拉刷新功能
5.UIScrollView方法介绍
显示内容区域相关的方法
- (void)adjustedContentInsetDidChange API_AVAILABLE(ios(11.0),tvos(11.0)) NS_REQUIRES_SUPER;
监听adjustedContentInset属性值的改变,在adjustedContentInset属性值改变时会调用
- (void)setContentOffset:(CGPoint)contentOffset animated:(BOOL)animated;
改变 contenOffset属性值,让 scrollview显示内容的左上角原点偏移到相对于scrollView原点的指定位置
- (void)scrollRectToVisible:(CGRect)rect animated:(BOOL)animated;
该方法用于将scrollView坐标系内的一块指定区域滚到到刚好可见处,如果这部分已经存在于可见的窗口中,则什么也不做。
滚动条相关方法
- (void)flashScrollIndicators;
该方法用于闪动一下滚动条. 建议在将scrollView展示给用户时调用一下, 以提醒用户该控件可以滚动,当你将scrollView调整到最上面时,需要调用一下该方法。
触摸相关方法
- (BOOL)touchesShouldBegin:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event inContentView:(UIView *)view;
该方法用于在UIScrollView的子类中重写, 返回是否将事件传递给对应的子视图, 默认返回YES. 如果返回NO, 则该事件不会传递给对应的子视图
- (BOOL)touchesShouldCancelInContentView:(UIView *)view;
该方法用于在UIScrollView的子类中重写, 返回当已经将事件传递给子视图后是否可以取消. 默认当子视图是UIControl时返回NO, 即不再继续跟踪用户的触摸事件; 否则返回YES, 即仍然继续跟踪用户的触摸事件,该方法被调用的前提是canCancelContentTouches = YES
缩放相关方法
- (void)setZoomScale:(CGFloat)scale animated:(BOOL)animated NS_AVAILABLE_IOS(3_0);
设置缩放比例
- (void)zoomToRect:(CGRect)rect animated:(BOOL)animated NS_AVAILABLE_IOS(3_0);
该方法用于将内容缩放到指定区域
6.UIScrollView代理方法介绍
滚动相关
- (void)scrollViewDidScroll:(UIScrollView *)scrollView;
该方法在contentOffset发生变化时调用
- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView;
该方法在将要开始拖拽时调用,也可能需要先滑动一段时间或距离才会被调用
- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset NS_AVAILABLE_IOS(5_0);
该方法在用户停止拖拽时调用,可以通过修改targetContentOffset参数的值来调整停止的位置
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate;
该方法在用户停止拖拽时调用,如果在停止拖拽后继续移动, 则decelerate参数为YES
- (void)scrollViewWillBeginDecelerating:(UIScrollView *)scrollView;
该方法在将要开始减速时调用,仅当停止拖拽后继续移动时才会被调用
- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView;
该方法在已经结束减速时调用,仅当停止拖拽后继续移动时才会被调用
- (void)scrollViewDidEndScrollingAnimation:(UIScrollView *)scrollView;
该方法在-setContentOffset:animated:/-scrollRectVisible:animated:方法动画结束时调用,仅当animated设置为YES时才调用
- (BOOL)scrollViewShouldScrollToTop:(UIScrollView *)scrollView;
该方法用于返回是否允许点击状态栏让scrollView滑动到顶部, 默认值为YES,仅当scrollsToTop属性值为YES时才调用
- (void)scrollViewDidScrollToTop:(UIScrollView *)scrollView;
该方法在scrollView已经滑动到顶部时调用,仅当通过点击状态栏让scrollView滑动到顶部才调用
- (void)scrollViewDidChangeAdjustedContentInset:(UIScrollView *)scrollView API_AVAILABLE(ios(11.0), tvos(11.0));
该方法在adjustedContentInset发生变化时调用
缩放相关
- (void)scrollViewDidZoom:(UIScrollView *)scrollView NS_AVAILABLE_IOS(3_2);
该方法在缩放比例发生变化时调用
- (nullable UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView;
该方法用于返回参与缩放的子视图
- (void)scrollViewWillBeginZooming:(UIScrollView *)scrollView withView:(nullable UIView *)view NS_AVAILABLE_IOS(3_2);
该方法在将要开始缩放时调用
- (void)scrollViewDidEndZooming:(UIScrollView *)scrollView withView:(nullable UIView *)view atScale:(CGFloat)scale;
该方法在已经结束缩放时调用,返回scale值在
minimumZoomScale
和maximumZoomScale
之间
7.ScrollView事件处理实现原理
由于scrollView
并没有用于直接操控的滚动条, 因此用户只能通过直接操作scrollView的内容区域以便进行滚动操作. 但是当用户触碰到屏幕上时, scrollView
并不清楚该用户的目的是想要进行滚动操作还是单纯地想要点击某一个视图. 为了处理这种情况, 当用户触碰屏幕时, scrollView
首先拦截到该触摸事件并启用一个150s的定时器, 同时观察用户的下一步行为.
当定时器结束前, 如果用户的触摸点发生足够的移动, 则直接滚动内容区域, 并且不会继续将该触摸事件传递给子视图.
当定时器结束后, 如果用户的触摸点并没有发生足够的移动, 则调用-
touchesShouldBegin:withEvent:inContentView:
方法询问是否将事件传递给对应的子视图. 如果返回NO, 则该事件不会传递给对应的子视图; 如果返回YES, 则该事件会传递给对应的子视图, 默认为YES.当触摸事件被传递给子视图后, 如果
canCancelContentTouches=YES
, 则会立即调用-touchesShouldCancelInContentView:
方法询问是否可以取消已经传递给子视图的事件. 如果返回NO, 则不再进一步跟踪用户的触摸事件; 如果返回YES, 则当用户的触摸点又发生足够的移动时, 系统会向该子视图发送-touchesCancelled:withEvent:
消息并进行滑动.