適配器把自己封裝起來(lái)然后暴露統(tǒng)一的接口給其他類,這樣即使其他類的接口各不相同,也能相安無(wú)事,一起工作。
如果你熟悉適配器模式,那么你會(huì)發(fā)現(xiàn)蘋果在實(shí)現(xiàn)適配器模式的方式稍有不同:蘋果通過委托實(shí)現(xiàn)了適配器模式。委托相信大家都不陌生。舉個(gè)例子,如果一個(gè)類遵循了 NSCoying
的協(xié)議,那么它一定要實(shí)現(xiàn) copy
方法。
橫滑的滾動(dòng)欄理論上應(yīng)該是這個(gè)樣子的:
新建一個(gè) Swift 文件:HorizontalScroller.swift
,作為我們的橫滑滾動(dòng)控件, HorizontalScroller
繼承自 UIView
。
打開 HorizontalScroller.swift
文件并添加如下代碼:
@objc protocol HorizontalScrollerDelegate {
}
這行代碼定義了一個(gè)新的協(xié)議: HorizontalScrollerDelegate
。我們?cè)谇懊婕由狭?@objc
的標(biāo)記,這樣我們就可以像在 objc 里一樣使用 @optional
的委托方法了。
接下來(lái)我們?cè)诖罄ㄌ?hào)里定義所有的委托方法,包括必須的和可選的:
// 在橫滑視圖中有多少頁(yè)面需要展示
func numberOfViewsForHorizontalScroller(scroller: HorizontalScroller) -> Int
// 展示在第 index 位置顯示的 UIView
func horizontalScrollerViewAtIndex(scroller: HorizontalScroller, index:Int) -> UIView
// 通知委托第 index 個(gè)視圖被點(diǎn)擊了
func horizontalScrollerClickedViewAtIndex(scroller: HorizontalScroller, index:Int)
// 可選方法,返回初始化時(shí)顯示的圖片下標(biāo),默認(rèn)是0
optional func initialViewIndex(scroller: HorizontalScroller) -> Int
其中,沒有 option
標(biāo)記的方法是必須實(shí)現(xiàn)的,一般來(lái)說(shuō)包括那些用來(lái)顯示的必須數(shù)據(jù),比如如何展示數(shù)據(jù),有多少數(shù)據(jù)需要展示,點(diǎn)擊事件如何處理等等,不可或缺;有 option
標(biāo)記的方法為可選實(shí)現(xiàn)的,相當(dāng)于是一些輔助設(shè)置和功能,就算沒有實(shí)現(xiàn)也有默認(rèn)值進(jìn)行處理。
在 HorizontalScroller
類里添加一個(gè)新的委托對(duì)象:
weak var delegate: HorizontalScrollerDelegate?
為了避免循環(huán)引用的問題,委托是 weak
類型。如果委托是 strong
類型的,當(dāng)前對(duì)象持有了委托的強(qiáng)引用,委托又持有了當(dāng)前對(duì)象的強(qiáng)引用,這樣誰(shuí)都無(wú)法釋放就會(huì)導(dǎo)致內(nèi)存泄露。
委托是可選類型,所以很有可能當(dāng)前類的使用者并沒有指定委托。但是如果指定了委托,那么它一定會(huì)遵循 HorizontalScrollerDelegate
里約定的內(nèi)容。
再添加一些新的屬性:
// 1
private let VIEW_PADDING = 10
private let VIEW_DIMENSIONS = 100
private let VIEWS_OFFSET = 100
// 2
private var scroller : UIScrollView!
// 3
var viewArray = [UIView]()
上面標(biāo)注的三點(diǎn)分別做了這些事情:
UIScrollView
作為容器。接下來(lái)實(shí)現(xiàn)初始化方法:
override init(frame: CGRect) {
super.init(frame: frame)
initializeScrollView()
}
required init(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
initializeScrollView()
}
func initializeScrollView() {
//1
scroller = UIScrollView()
addSubview(scroller)
//2
scroller.setTranslatesAutoresizingMaskIntoConstraints(false)
//3
self.addConstraint(NSLayoutConstraint(item: scroller, attribute: .Leading, relatedBy: .Equal, toItem: self, attribute: .Leading, multiplier: 1.0, constant: 0.0))
self.addConstraint(NSLayoutConstraint(item: scroller, attribute: .Trailing, relatedBy: .Equal, toItem: self, attribute: .Trailing, multiplier: 1.0, constant: 0.0))
self.addConstraint(NSLayoutConstraint(item: scroller, attribute: .Top, relatedBy: .Equal, toItem: self, attribute: .Top, multiplier: 1.0, constant: 0.0))
self.addConstraint(NSLayoutConstraint(item: scroller, attribute: .Bottom, relatedBy: .Equal, toItem: self, attribute: .Bottom, multiplier: 1.0, constant: 0.0))
//4
let tapRecognizer = UITapGestureRecognizer(target: self, action:Selector("scrollerTapped:"))
scroller.addGestureRecognizer(tapRecognizer)
}
上面的代碼做了如下工作:
UIScrollView
對(duì)象并且把它加到父視圖中。autoresizing masks
,從而可以使用 AutoLayout
進(jìn)行布局。scrollview
添加約束。我們希望 scrollview
能填滿 HorizontalScroller
。HorizontalScroller
的委托。添加委托方法:
func scrollerTapped(gesture: UITapGestureRecognizer) {
let location = gesture.locationInView(gesture.view)
if let delegate = self.delegate {
for index in 0..<delegate.numberOfViewsForHorizontalScroller(self) {
let view = scroller.subviews[index] as UIView
if CGRectContainsPoint(view.frame, location) {
delegate.horizontalScrollerClickedViewAtIndex(self, index: index)
scroller.setContentOffset(CGPointMake(view.frame.origin.x - self.frame.size.width/2 + view.frame.size.width/2, 0), animated:true)
break
}
}
}
}
我們把 gesture 作為一個(gè)參數(shù)傳了進(jìn)來(lái),這樣就可以獲取點(diǎn)擊的具體坐標(biāo)了。
接下來(lái)我們調(diào)用了 numberOfViewsForHorizontalScroller 方法,HorizontalScroller 不知道自己的 delegate 具體是誰(shuí),但是知道它一定實(shí)現(xiàn)了 HorizontalScrollerDelegate 協(xié)議,所以可以放心的調(diào)用。
對(duì)于 scroll view 中的 view ,通過 CGRectContainsPoint 進(jìn)行點(diǎn)擊檢測(cè),從而獲知是哪一個(gè) view 被點(diǎn)擊了。當(dāng)找到了點(diǎn)擊的 view 的時(shí)候,則會(huì)調(diào)用委托方法里的 horizontalScrollerClickedViewAtIndex 方法通知委托。在跳出 for 循環(huán)之前,先把點(diǎn)擊到的 view 居中。
接下來(lái)我們?cè)偌觽€(gè)方法獲取數(shù)組里的 view :
func viewAtIndex(index :Int) -> UIView {
return viewArray[index]
}
這個(gè)方法很簡(jiǎn)單,只是用來(lái)更方便獲取數(shù)組里的 view
而已。在后面實(shí)現(xiàn)高亮選中專輯的時(shí)候會(huì)用到這個(gè)方法。
添加如下代碼用來(lái)重新加載 scroller
:
func reload() {
// 1 - Check if there is a delegate, if not there is nothing to load.
if let delegate = self.delegate {
//2 - Will keep adding new album views on reload, need to reset.
viewArray = []
let views: NSArray = scroller.subviews
// 3 - remove all subviews
views.enumerateObjectsUsingBlock {
(object: AnyObject!, idx: Int, stop: UnsafeMutablePointer<ObjCBool>) -> Void in
object.removeFromSuperview()
}
// 4 - xValue is the starting point of the views inside the scroller
var xValue = VIEWS_OFFSET
for index in 0..<delegate.numberOfViewsForHorizontalScroller(self) {
// 5 - add a view at the right position
xValue += VIEW_PADDING
let view = delegate.horizontalScrollerViewAtIndex(self, index: index)
view.frame = CGRectMake(CGFloat(xValue), CGFloat(VIEW_PADDING), CGFloat(VIEW_DIMENSIONS), CGFloat(VIEW_DIMENSIONS))
scroller.addSubview(view)
xValue += VIEW_DIMENSIONS + VIEW_PADDING
// 6 - Store the view so we can reference it later
viewArray.append(view)
}
// 7
scroller.contentSize = CGSizeMake(CGFloat(xValue + VIEWS_OFFSET), frame.size.height)
// 8 - If an initial view is defined, center the scroller on it
if let initialView = delegate.initialViewIndex?(self) {
scroller.setContentOffset(CGPointMake(CGFloat(initialView)*CGFloat((VIEW_DIMENSIONS + (2 * VIEW_PADDING))), 0), animated: true)
}
}
}
這個(gè) reload 方法有點(diǎn)像是 UITableView 里面的 reloadData 方法,它會(huì)重新加載所有數(shù)據(jù)。
一段一段的看下上面的代碼:
當(dāng)數(shù)據(jù)發(fā)生改變的時(shí)候,我們需要調(diào)用 reload 方法。當(dāng) HorizontalScroller 被加到其他頁(yè)面的時(shí)候也需要調(diào)用這個(gè)方法,我們?cè)?HorizontalScroller.swift 里面加入如下代碼:
override func didMoveToSuperview() {
reload()
}
在當(dāng)前 view 添加到其他 view 里的時(shí)候就會(huì)自動(dòng)調(diào)用 didMoveToSuperview 方法,這樣可以在正確的時(shí)間重新加載數(shù)據(jù)。
HorizontalScroller 的最后一部分是用來(lái)確保當(dāng)前瀏覽的內(nèi)容時(shí)刻位于正中心的位置,為了實(shí)現(xiàn)這個(gè)功能我們需要在用戶滑動(dòng)結(jié)束的時(shí)候做一些額外的計(jì)算和修正。
添加下面這個(gè)方法:
func centerCurrentView() {
var xFinal = scroller.contentOffset.x + CGFloat((VIEWS_OFFSET/2) + VIEW_PADDING)
let viewIndex = xFinal / CGFloat((VIEW_DIMENSIONS + (2*VIEW_PADDING)))
xFinal = viewIndex * CGFloat(VIEW_DIMENSIONS + (2*VIEW_PADDING))
scroller.setContentOffset(CGPointMake(xFinal, 0), animated: true)
if let delegate = self.delegate {
delegate.horizontalScrollerClickedViewAtIndex(self, index: Int(viewIndex))
}
}
上面的代碼計(jì)算了當(dāng)前視圖里中心位置距離多少,然后算出正確的居中坐標(biāo)并滑動(dòng)到那個(gè)位置。最后一行是通知委托所選視圖已經(jīng)發(fā)生了改變。
為了檢測(cè)到用戶滑動(dòng)的結(jié)束時(shí)間,我們還需要實(shí)現(xiàn) UIScrollViewDelegate 的方法。在文件結(jié)尾加上下面這個(gè)擴(kuò)展:
extension HorizontalScroller: UIScrollViewDelegate {
func scrollViewDidEndDragging(scrollView: UIScrollView, willDecelerate decelerate: Bool) {
if !decelerate {
centerCurrentView()
}
}
func scrollViewDidEndDecelerating(scrollView: UIScrollView) {
centerCurrentView()
}
}
當(dāng)用戶停止滑動(dòng)的時(shí)候,scrollViewDidEndDragging(_:willDecelerate:) 這個(gè)方法會(huì)通知委托。如果滑動(dòng)還沒有停止,decelerate 的值為 true 。當(dāng)滑動(dòng)完全結(jié)束的時(shí)候,則會(huì)調(diào)用 scrollViewDidEndDecelerating 這個(gè)方法。在這兩種情況下,你都應(yīng)該把當(dāng)前的視圖居中,因?yàn)橛脩舻牟僮骺赡軙?huì)改變當(dāng)前視圖。
你的 HorizontalScroller 已經(jīng)可以使用了!回頭看看前面寫的代碼,你會(huì)看到我們并沒有涉及什么 Album 或者 AlbumView 的代碼。這是極好的,因?yàn)檫@樣意味著這個(gè) scroller 是完全獨(dú)立的,可以復(fù)用。
運(yùn)行一下你的項(xiàng)目,確保編譯通過。
這樣,我們的 HorizontalScroller 就完成了,接下來(lái)我們就要把它應(yīng)用到我們的項(xiàng)目里了。首先,打開 Main.Sstoryboard 文件,點(diǎn)擊上面的灰色矩形,設(shè)置 Class 為 HorizontalScroller :
接下來(lái),在 assistant editor 模式下向 ViewController.swift 拖拽生成 outlet ,命名為 scroller :
接下來(lái)打開 ViewController.swift 文件,是時(shí)候?qū)崿F(xiàn) HorizontalScrollerDelegate 委托里的方法啦!
添加如下擴(kuò)展:
extension ViewController: HorizontalScrollerDelegate {
func horizontalScrollerClickedViewAtIndex(scroller: HorizontalScroller, index: Int) {
//1
let previousAlbumView = scroller.viewAtIndex(currentAlbumIndex) as AlbumView
previousAlbumView.highlightAlbum(didHighlightView: false)
//2
currentAlbumIndex = index
//3
let albumView = scroller.viewAtIndex(index) as AlbumView
albumView.highlightAlbum(didHighlightView: true)
//4
showDataForAlbum(index)
}
}
讓我們一行一行的看下這個(gè)委托的實(shí)現(xiàn):
接下來(lái)在擴(kuò)展里添加如下方法:
func numberOfViewsForHorizontalScroller(scroller: HorizontalScroller) -> (Int) {
return allAlbums.count
}
這個(gè)委托方法返回 scroll vew
里面的視圖數(shù)量,因?yàn)槭怯脕?lái)展示所有的專輯的封面,所以數(shù)目也就是專輯數(shù)目。
然后添加如下代碼:
func horizontalScrollerViewAtIndex(scroller: HorizontalScroller, index: Int) -> (UIView) {
let album = allAlbums[index]
let albumView = AlbumView(frame: CGRectMake(0, 0, 100, 100), albumCover: album.coverUrl)
if currentAlbumIndex == index {
albumView.highlightAlbum(didHighlightView: true)
} else {
albumView.highlightAlbum(didHighlightView: false)
}
return albumView
}
我們創(chuàng)建了一個(gè)新的 AlbumView
,然后檢查一下是不是當(dāng)前選中的專輯,如果是則設(shè)為高亮,最后返回結(jié)果。
是的就是這么簡(jiǎn)單!三個(gè)方法,完成了一個(gè)橫向滾動(dòng)的瀏覽視圖。
我們還需要?jiǎng)?chuàng)建這個(gè)滾動(dòng)視圖并把它加到主視圖里,但是在這之前,先添加如下方法:
func reloadScroller() {
allAlbums = LibraryAPI.sharedInstance.getAlbums()
if currentAlbumIndex < 0 {
currentAlbumIndex = 0
} else if currentAlbumIndex >= allAlbums.count {
currentAlbumIndex = allAlbums.count - 1
}
scroller.reload()
showDataForAlbum(currentAlbumIndex)
}
這個(gè)方法通過 LibraryAPI
加載專輯數(shù)據(jù),然后根據(jù) currentAlbumIndex
的值設(shè)置當(dāng)前視圖。在設(shè)置之前先進(jìn)行了校正,如果小于0則設(shè)置第一個(gè)專輯為展示的視圖,如果超出了范圍則設(shè)置最后一個(gè)專輯為展示的視圖。
接下來(lái)只需要指定委托就可以了,在 viewDidLoad
最后加入一下代碼:
scroller.delegate = self
reloadScroller()
因?yàn)?HorizontalScroller
是在 StoryBoard
里初始化的,所以我們需要做的只是指定委托,然后調(diào)用 reloadScroller()
方法,從而加載所有的子視圖并且展示專輯數(shù)據(jù)。
標(biāo)注:如果協(xié)議里的方法過多,可以考慮把它分解成幾個(gè)更小的協(xié)議。UITableViewDelegate
和 UITableViewDataSource
就是很好的例子,它們都是 UITableView
的協(xié)議。嘗試去設(shè)計(jì)你自己的協(xié)議,讓每個(gè)協(xié)議都單獨(dú)負(fù)責(zé)一部分功能。
運(yùn)行一下當(dāng)前項(xiàng)目,看一下我們的新頁(yè)面:
等下,滾動(dòng)視圖顯示出來(lái)了,但是專輯的封面怎么不見了?
啊哈,是的。我們還沒完成下載部分的代碼,我們需要添加下載圖片的方法。因?yàn)槲覀兯械脑L問都是通過 LibraryAPI
實(shí)現(xiàn)的,所以很顯然我們下一步應(yīng)該去完善這個(gè)類了。不過在這之前,我們還需要考慮一些問題:
AlbumView
不應(yīng)該直接和 LibraryAPI
交互,我們不應(yīng)該把視圖的邏輯和業(yè)務(wù)邏輯混在一起。LibraryAPI
也不應(yīng)該知道 AlbumView
這個(gè)類。AlbumView
要展示封面,LibraryAPI
需要告訴 AlbumView
圖片下載完成。看起來(lái)好像很難的樣子?別絕望,接下來(lái)我們會(huì)用觀察者模式 (Observer Pattern
) 解決這個(gè)問題!
更多建議: