很多东西我们只是在用,深入一步去了解其实现原理,可以加强我们对用到东西的认识,更好的去使用,可以提升我们得认识事物的深度,加强基础。这样才能更好的去使用和创造新的东西。
本文我们尝试着去理解UIScrollView的实现原理,并自己去实现一个简单的ScrollView。
UIScrollView基础
滚动视图,支持内容滚动、缩放等功能。
- contentSize
scrollView内容的大小,contentSize是CGSzie类型的,可以设置scrollView内容的长和宽。
例如这样设置 self.scrollView.contentSize = CGSizeMake(CGRectGetWidth(self.view.frame), CGRectGetHeight(self.view.frame*2));滚动范围水平上不滚动,竖直方向上可以滚动一个屏的高度,即可以展示两个屏幕高度的视图内容。
- contentOffset
CGPoint类型,内容左上角相对ScrollView左上角的点。这个属性可以确定ScrollView的滚动位置。
- contentInset
UIEdgeInsets类型,这个属性可以给ScrollView的边增加额外的滚动区域。
例如这样设置
self.scrollView.contentSize = CGSizeMake(CGRectGetWidth(self.view.frame), CGRectGetHeight(self.view.frame));
self.scrollView.contentInset = UIEdgeInsetsMake(100, 100, 100, 100);
contentSize设置了内容大小就是整个屏幕大小,而可滑动的范围上左下右各是100。
img
效果是这样的:黑灰色是ScroollView,棕色是ScrollView上的一个子视图,我们可以滑动ScroollView内容视图,并超出ScrollView。contentInset就是设置了超出的范围。
UIScrollView的滚动原理
滚动原理
- frame:该view在父view坐标系统中的位置和大小,以父视图左上角为坐标0点。
- bounds:该view在本地坐标系统中的位置和大小,以view自己的坐标系为参照,以自己初始位置为0点。
滚动时scrollView的frame是不会变的,变的是bounds的origin。
我们可以给视图上添加一个ScrollView,滚动时候通过其代理来观察frame和bounds的变化。
- (void)scrollViewDidScroll:(UIScrollView *)scrollView
{
NSLog(@"frame:%@ bounds:%@",NSStringFromCGRect(self.view.frame),NSStringFromCGRect(self.scrollView.bounds));
}
得出的结论就是,frame是一直不变的,bounds的origin.x和origin.y在变化,这个取决于左右滚动还是上下滚动。
滚动原理实现
知道ScrollView的滚动原理后,结合原理试着去实现一个可滚动的ScrollView,这样更加加深我们对ScroolView的认识和理解。
- 首先创建一个继承UIView的View
创建一个普通的View-MMScrollView,这个View就是我们要实现的ScroolView。
- 必要属性
ScrollView有一些基础的属性,contentSize、panGestureRecognizer,这两个属性是实现ScrollView的滚动必不可少的属性。
- 实现滚动
创建panGestureRecognizer手势,结合contentSize,在pan手势的方法里来实现实现滚动控制:
- (void)panGesRecognizerAction:(UIPanGestureRecognizer *)panGestureRecognizer
{
if (panGestureRecognizer.state == UIGestureRecognizerStateChanged)
{
CGPoint translation = [panGestureRecognizer translationInView:self.superview];
CGRect bounds = self.bounds;
bounds.origin.x -= translation.x;
bounds.origin.y -= translation.y;
// It will never scroll in horizontal direction if the contentSize.width > CGRectGetWidth(self.frame)
if (self.contentSize.width > CGRectGetWidth(self.frame)) {
if (bounds.origin.x < 0) {
bounds.origin.x = 0;
}
if (bounds.origin.x+CGRectGetWidth(self.frame) >= self.contentSize.width) {
bounds.origin.x = self.contentSize.width-CGRectGetWidth(self.frame);
}
}
else {
bounds.origin.x = 0;
}
// It will never scroll in vertically direction if the self.contentSize.height > CGRectGetHeight(self.frame)
if (self.contentSize.height > CGRectGetHeight(self.frame))
{
if (bounds.origin.y < 0) {
bounds.origin.y = 0;
}
if (bounds.origin.y+CGRectGetHeight(self.frame) >= self.contentSize.height) {
bounds.origin.y = self.contentSize.height-CGRectGetHeight(self.frame);
}
} else {
bounds.origin.y = 0;
}
self.bounds = bounds;
[panGestureRecognizer setTranslation:CGPointZero inView:self];
}
}
我这里的实现算法比较low,大概看看就好了,肯定会有更好的实现方式。
这里是完整代码:
// MMScrollView.h
@interface MMScrollView : UIView
@property (nonatomic) CGSize contentSize; // default CGSizeZero. Set the scrollview content boundary.
// Change `panGestureRecognizer.allowedTouchTypes` to limit scrolling to a particular set of touch types.
@property(nonatomic, readonly) UIPanGestureRecognizer *panGestureRecognizer;
@end
// MMScrollView.m
#import "MMScrollView.h"
@interface MMScrollView ()
@property (nonatomic, readwrite) UIPanGestureRecognizer *panGestureRecognizer;
@end
@implementation MMScrollView
- (instancetype)initWithFrame:(CGRect)frame {
if (self = [super initWithFrame:frame]) {
self.panGestureRecognizer = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(panGesRecognizerAction:)];
[self addGestureRecognizer:self.panGestureRecognizer];
}
return self;
}
- (void)panGesRecognizerAction:(UIPanGestureRecognizer *)panGestureRecognizer
{
if (panGestureRecognizer.state == UIGestureRecognizerStateChanged)
{
CGPoint translation = [panGestureRecognizer translationInView:self.superview];
CGRect bounds = self.bounds;
bounds.origin.x -= translation.x;
bounds.origin.y -= translation.y;
if (self.contentSize.width > CGRectGetWidth(self.frame)) {
if (bounds.origin.x < 0) {
bounds.origin.x = 0;
}
if (bounds.origin.x+CGRectGetWidth(self.frame) >= self.contentSize.width) {
bounds.origin.x = self.contentSize.width-CGRectGetWidth(self.frame);
}
}
else {
bounds.origin.x = 0;
}
if (self.contentSize.height > CGRectGetHeight(self.frame))
{
if (bounds.origin.y < 0) {
bounds.origin.y = 0;
}
if (bounds.origin.y+CGRectGetHeight(self.frame) >= self.contentSize.height) {
bounds.origin.y = self.contentSize.height-CGRectGetHeight(self.frame);
}
} else {
bounds.origin.y = 0;
}
self.bounds = bounds;
[panGestureRecognizer setTranslation:CGPointZero inView:self];
}
}
@end
我们可以在ViewController里面测试一下我们实现的MMScrollView:
初始化一个MMScrollView,添加到视图上,在上面添加一些子视图,设置contentSize属性,就可以滚动了。
这里我们加一个队ScrollView的bounds得监听,通过监听来看其bounds值得变化。
#import "ViewController.h"
#import "MMScrollView.h"
@interface ViewController ()
@property (nonatomic, strong) MMScrollView *myScrollView;
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.myScrollView = [[MMScrollView alloc] initWithFrame:self.view.bounds];
self.myScrollView.contentSize = CGSizeMake(0, CGRectGetHeight(self.view.frame)*2);
[self.view addSubview:self.myScrollView];
for (int i = 0; i < 20; i ++) {
UIView *subView = [[UIView alloc] initWithFrame:CGRectMake(100, 10+i*(100+10), 100, 100)];
subView.backgroundColor = [ViewController randomColor];
[self.myScrollView addSubview:subView];
}
[self.myScrollView addObserver:self forKeyPath:@"bounds" options:NSKeyValueObservingOptionNew context:nil];
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context
{
if ([keyPath isEqualToString:@"bounds"]) {
NSLog(@"%@",NSStringFromCGRect(self.myScrollView.bounds));
}
}
+ (UIColor *)randomColor {
CGFloat hue = (arc4random() %256/256.0);
CGFloat saturation = (arc4random() %128/256.0) +0.5;
CGFloat brightness = (arc4random() %128/256.0) +0.5;
return [UIColor colorWithHue:hue saturation:saturation brightness:brightness alpha:1.0];
}
@end
我使用iPad Air横屏进行的测试。
当ScrolllView滚动到最下面的时候,打印值是{{0, 834}, {1112, 834}},当滚动到最顶部的时候打印值是{{0, 0}, {1112, 834}}。833和1112分别是横屏下屏幕的高和宽。
我们可以看只有origin.y在变化,因为我们实现的竖直方向上的滚动。
从顶部{{0, 0}, {1112, 834}},到底部{{0, 834}, {1112, 834}},滚动了一整个屏,滚动范围是两个屏幕的高,这是我们属性contentSize里设置的。
UIScrollView的缩放原理
缩放原理
ScrollView实现缩放,是通过setZoomScale来实现的,通过设置可以使其子视图进行放大和缩小。代理方法中需要返回要设置缩放的视图,ScrollView的内部通过代理方法拿到需要缩放的视图并对其进行操作。
// return a view that will be scaled.
- (UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView
{
return self.containerView;
}
// set the imageView zoom scale
self.scrollView.minimumZoomScale = 0.5;
self.scrollView.maximumZoomScale = 2;
缩放的时候,视图大小、位置等也发生了改变。还可以对其进行移动、旋转等操作,这实质上是通过transform来实现的。
- (void)viewDidLoad {
[super viewDidLoad];
// Do any additional setup after loading the view.
self.scrollView = [[UIScrollView alloc] initWithFrame:self.view.bounds];
self.scrollView.showsVerticalScrollIndicator = NO;
self.scrollView.showsHorizontalScrollIndicator = NO;
self.scrollView.backgroundColor = [UIColor clearColor];
self.scrollView.delegate = self;
self.scrollView.minimumZoomScale = 0.5;
self.scrollView.maximumZoomScale = 2;
[self.view addSubview:self.scrollView];
self.containerView = [[UIView alloc] initWithFrame:self.view.bounds];
self.containerView.backgroundColor = [UIColor lightGrayColor];
[self.scrollView addSubview:self.containerView];
}
// return a view that will be scaled.
- (UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView
{
return self.containerView;
}
// call back any zoom scale changes
- (void)scrollViewDidZoom:(UIScrollView *)scrollView
{
// CGFloat offsetX = (scrollView.bounds.size.width > scrollView.contentSize.width) ? (scrollView.bounds.size.width - scrollView.contentSize.width)/2 : 0.0;
// CGFloat offsetY = (scrollView.bounds.size.height > scrollView.contentSize.height) ? (scrollView.bounds.size.height - scrollView.contentSize.height)/2 : 0.0;
// self.containerView.center = CGPointMake(scrollView.contentSize.width/2 + offsetX,scrollView.contentSize.height/2 + offsetY);
// give a center point to scaled view.
self.containerView.center = self.view.center;
}
在scrollViewDidZoom里做处理,可以实现居中缩放的功能。
缩放原理实现
通过实验我发现当缩放的时候,ScrollView本身也会被缩放
- (void)pinchGestureRecognizerAction:(UIPinchGestureRecognizer *)pinGestureRecognizer
{
if ([self.delegate respondsToSelector:@selector(viewForZoomingInScrollView:)])
{
if (pinGestureRecognizer.state == UIGestureRecognizerStateEnded)
{
self.currentScale = pinGestureRecognizer.scale;
}
else if(pinGestureRecognizer.state == UIGestureRecognizerStateBegan && self.currentScale != 0.0f)
{
pinGestureRecognizer.scale = self.currentScale;
}
if (pinGestureRecognizer.scale !=NAN && pinGestureRecognizer.scale != 0.0)
{
pinGestureRecognizer.view.transform = CGAffineTransformMakeScale(pinGestureRecognizer.scale, pinGestureRecognizer.scale);
}
}
}
下面是整合了滚动和缩放功能的ScrollView的完整代码:
- MMScrollView.h
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
@class MMScrollView;
@protocol MMScrollViewDelegate <NSObject>
@optional
- (nullable UIView *)viewForZoomingInScrollView:(MMScrollView *)scrollView; // return a view that will be scaled. if delegate returns nil, nothing happens
@end
@interface MMScrollView : UIView
@property (nonatomic) CGSize contentSize; // default CGSizeZero. Set the scrollview content boundary.
// Change `panGestureRecognizer.allowedTouchTypes` to limit scrolling to a particular set of touch types.
@property(nonatomic, readonly) UIPanGestureRecognizer *panGestureRecognizer;
// `pinchGestureRecognizer` will return nil when zooming is disabled.
@property(nullable, nonatomic, readonly) UIPinchGestureRecognizer *pinchGestureRecognizer;
// delegate
@property (nonatomic, weak) id <MMScrollViewDelegate> delegate;
@end
NS_ASSUME_NONNULL_END
- MMScrollView.m
#import "MMScrollView.h"
@interface MMScrollView ()
@property (nonatomic, readwrite) UIPanGestureRecognizer *panGestureRecognizer;
@property(nullable, nonatomic, readwrite) UIPinchGestureRecognizer *pinchGestureRecognizer;
@property (nonatomic) CGFloat currentScale;
@end
@implementation MMScrollView
- (UIPinchGestureRecognizer *)pinchGestureRecognizer {
if (!_pinchGestureRecognizer) {
_pinchGestureRecognizer = [[UIPinchGestureRecognizer alloc] initWithTarget:self action:@selector(pinchGestureRecognizerAction:)];
}
return _pinchGestureRecognizer;
}
- (instancetype)initWithFrame:(CGRect)frame {
if (self = [super initWithFrame:frame]) {
self.panGestureRecognizer = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(panGesRecognizerAction:)];
self.panGestureRecognizer.cancelsTouchesInView = YES;
[self addGestureRecognizer:self.panGestureRecognizer];
}
return self;
}
- (void)setDelegate:(id<MMScrollViewDelegate>)delegate
{
_delegate = delegate;
if ([_delegate respondsToSelector:@selector(viewForZoomingInScrollView:)]) {
if (![self.gestureRecognizers containsObject:self.pinchGestureRecognizer]) {
[self addGestureRecognizer:self.pinchGestureRecognizer];
}
}
else {
if ([self.gestureRecognizers containsObject:self.pinchGestureRecognizer]) {
[self removeGestureRecognizer:self.pinchGestureRecognizer];
}
}
}
- (void)panGesRecognizerAction:(UIPanGestureRecognizer *)panGestureRecognizer
{
if (panGestureRecognizer.state == UIGestureRecognizerStateChanged)
{
CGPoint translation = [panGestureRecognizer translationInView:self.superview];
CGRect bounds = self.bounds;
bounds.origin.x -= translation.x;
bounds.origin.y -= translation.y;
if (self.contentSize.width > CGRectGetWidth(self.frame)) {
if (bounds.origin.x < 0) {
bounds.origin.x = 0;
}
if (bounds.origin.x+CGRectGetWidth(self.frame) >= self.contentSize.width) {
bounds.origin.x = self.contentSize.width-CGRectGetWidth(self.frame);
}
}
else {
bounds.origin.x = 0;
}
if (self.contentSize.height > CGRectGetHeight(self.frame))
{
if (bounds.origin.y < 0) {
bounds.origin.y = 0;
}
if (bounds.origin.y+CGRectGetHeight(self.frame) >= self.contentSize.height) {
bounds.origin.y = self.contentSize.height-CGRectGetHeight(self.frame);
}
} else {
bounds.origin.y = 0;
}
self.bounds = bounds;
[panGestureRecognizer setTranslation:CGPointZero inView:self];
}
}
- (void)pinchGestureRecognizerAction:(UIPinchGestureRecognizer *)pinGestureRecognizer
{
if ([self.delegate respondsToSelector:@selector(viewForZoomingInScrollView:)])
{
if (pinGestureRecognizer.state == UIGestureRecognizerStateEnded)
{
self.currentScale = pinGestureRecognizer.scale;
}
else if(pinGestureRecognizer.state == UIGestureRecognizerStateBegan && self.currentScale != 0.0f)
{
pinGestureRecognizer.scale = self.currentScale;
}
if (pinGestureRecognizer.scale !=NAN && pinGestureRecognizer.scale != 0.0)
{
pinGestureRecognizer.view.transform = CGAffineTransformMakeScale(pinGestureRecognizer.scale, pinGestureRecognizer.scale);
}
}
}
@end
- ViewController
#import "ViewController.h"
#import "MMScrollView.h"
@interface ViewController ()<MMScrollViewDelegate>
@property (nonatomic, strong) MMScrollView *myScrollView;
@property (nonatomic, strong) UIImageView *imageView;
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.myScrollView = [[MMScrollView alloc] initWithFrame:self.view.bounds];
self.myScrollView.backgroundColor = [UIColor darkGrayColor];
self.myScrollView.delegate = self;
self.myScrollView.contentSize = CGSizeMake(CGRectGetWidth(self.view.frame), CGRectGetHeight(self.view.frame)*2);
[self.view addSubview:self.myScrollView];
self.imageView = [[UIImageView alloc] initWithFrame:self.myScrollView.bounds];
self.imageView.backgroundColor = [UIColor brownColor];
[self.myScrollView addSubview:self.imageView];
}
#pragma mark - MMScrollViewDelegate
// return a view that will be scaled. if delegate returns nil, nothing happens
- (nullable UIView *)viewForZoomingInScrollView:(MMScrollView *)scrollView
{
return self.imageView;
}
@end
这个示例代码当滚动和缩放都实现的时候是有bug的,但是并不妨碍我们通过它去探索UIScrollView的基本实现原理,不妨碍我们去探索UIScrollView滚动和缩放的本质。