我是Mike Ash的Let’s Build…系列文章的忠實粉絲,在這一系列文章中他從頭設計Cocoa的控制元件來解釋他們的工作原理。在這裡我要做一點類似的事情,用幾行程式碼來實現我自己的滾動試圖。不過首先,讓我們先來了解一下UIKit中的座標系是怎麼工作的。如果你只對滾動試圖的程式碼實現感興趣可以放心跳過下一小節。UIKit座標系每一個View都定義了他自己的座標系統。如下圖所示,x軸指向右方,y軸指向下方:
注意這個邏輯座標系並不關注包含在其中View的寬度和高度。整個座標系沒有邊界向四周無限延伸.我們在座標系中放置四個子View。每一次色塊代表一個View:
新增View的程式碼實現如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
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
Apple關於UIView的文件中是這樣描述bounds屬性的:
bounds矩形…描述了該檢視在其自身座標系中的位置和大小。
一個View可以被看作是定義在其所在座標系平面上的一扇窗戶或者說是一個矩形的可視區域。View的邊界表明了這個矩形可視區域的位置和大小。
假設我們的View寬320畫素,高480畫素,原點在(0,0)。那麼這個View就變成了整個座標系平面的觀察口,它展示的只是整個平面的一小部分。位於該View邊界外的區域依然存在,只是被隱藏起來了。
一個View提供了其所在平面的一個觀察口。View的bounds矩形描述了這個可是區域的位置和大小。
Frame
接下來我們來試著修改bounds的原點座標:
1 2 3 |
CGRect bounds = mainView.bounds; bounds.origin = CGPointMake(0, 100); mainView.bounds = bounds; |
當我們把bound原點設為(0,100)後,整個畫面看起來就像這樣:
修改bounds的原點就相當與在平面上移動這個可視區域。
看起來好像是這個View向下移動了100畫素,在這個View自己的座標系中這確實沒錯。不過這個View真正位於螢幕上的位置(更準確的說在其父View上的位置)其實沒有改變,因為這是由View的frame屬性決定的,它並沒有改變:
frame矩形…定義了這個View在其父View座標系中的位置和大小。
由於View的位置是相對固定的,你可以把整個座標平面想象成我們可以上下拖動的透明幕布,把這個View想象成我們觀察座標平面的視窗。調整View的Bounds屬性就相當於拖動這個幕布,那麼下方的內容就能在我們View中被觀察到:
Since the view’s position is fixed (from its own perspective), think of the coordinate system plane as a piece of transparent film we can drag around, and of the view as a fixed window we are looking through. Adjusting the bounds
’s origin is equivalent to moving the transparent film such that another part of it becomes visible through the view:
修改bounds的原點座標也相當於把整個座標系向上拖動,因為View的frame沒由變過,所以它相對於父View的位置沒有變化過。
其實這就是UIScrollView滑動時所發生的事情。注意從一個使用者的角度來看,他以為時這個View中的子View在移動,其實他們的在座標系中位置(他們的frame)沒有發生過變化。
打造你的UIScrollView
一個scroll view並不需要其中子View的座標來使他們滾動。唯一要做的就是改變他的bounds屬性。知道了這一點,實現一個簡單的scroll view就沒什麼困難了。我們用一個gesture recognizer來識別使用者的拖動操作,根據使用者拖動的偏移量來改變bounds的原點:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
// 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屬性,你必須從外部來設定這個值來指定可以滾動的區域,當我們改變bounds的大小時我們要確保設定的值是有效的。
結果:
我們的scroll view已經能夠工作了,不過還缺少動量滾動,反彈效果還有滾動提示符。
總結
感謝UIKit的座標系統特性,使我們之花了30幾行程式碼就能重現UIScrollView的精華,當然真正的UIScrollView要比我們所做的複雜的多,反彈效果,動量滾動,放大試圖,還有代理方法,這些特性我們沒有在這裡涉及到。
更新 5/ 2, 2014: 本文的程式碼在https://github.com/ole/CustomScrollView。
更新 5/ 8, 2014:
1.座標系並非無限延伸的。座標系的範圍由CGFloat的長度來決定,根據32位和64位系統有所不同,通常來講這是一個很大的值。
2.事實上,除非你設定clipToBounds == YES,所有子View超出的部分其實仍然是可見的。只是View不會再去檢測超出部分的觸控事件而已。