鍍金池/ 教程/ iOS/ 適配器模式 - Adapter
開始
裝飾者模式 - Decorator
單例模式 - Singleton
外觀模式 - Facade
觀察者模式 - Observer
準(zhǔn)備工作
iOS 設(shè)計模式
適配器模式 - Adapter
備忘錄模式 - Memento
最后的潤色
小結(jié)
設(shè)計模式之王- MVC

適配器模式 - Adapter

適配器把自己封裝起來然后暴露統(tǒng)一的接口給其他類,這樣即使其他類的接口各不相同,也能相安無事,一起工作。

如果你熟悉適配器模式,那么你會發(fā)現(xiàn)蘋果在實現(xiàn)適配器模式的方式稍有不同:蘋果通過委托實現(xiàn)了適配器模式。委托相信大家都不陌生。舉個例子,如果一個類遵循了 NSCoying 的協(xié)議,那么它一定要實現(xiàn) copy 方法。

如何使用適配器模式

橫滑的滾動欄理論上應(yīng)該是這個樣子的:

http://wiki.jikexueyuan.com/project/ios-design-patterns-in-swift/images/2.png" alt="" />

新建一個 Swift 文件:HorizontalScroller.swift ,作為我們的橫滑滾動控件, HorizontalScroller 繼承自 UIView 。

打開 HorizontalScroller.swift 文件并添加如下代碼:

@objc protocol HorizontalScrollerDelegate {
}

這行代碼定義了一個新的協(xié)議: HorizontalScrollerDelegate 。我們在前面加上了 @objc 的標(biāo)記,這樣我們就可以像在 objc 里一樣使用 @optional 的委托方法了。

接下來我們在大括號里定義所有的委托方法,包括必須的和可選的:

// 在橫滑視圖中有多少頁面需要展示
func numberOfViewsForHorizontalScroller(scroller: HorizontalScroller) -> Int
// 展示在第 index 位置顯示的 UIView
func horizontalScrollerViewAtIndex(scroller: HorizontalScroller, index:Int) -> UIView
// 通知委托第 index 個視圖被點(diǎn)擊了
func horizontalScrollerClickedViewAtIndex(scroller: HorizontalScroller, index:Int)
// 可選方法,返回初始化時顯示的圖片下標(biāo),默認(rèn)是0
optional func initialViewIndex(scroller: HorizontalScroller) -> Int

其中,沒有 option 標(biāo)記的方法是必須實現(xiàn)的,一般來說包括那些用來顯示的必須數(shù)據(jù),比如如何展示數(shù)據(jù),有多少數(shù)據(jù)需要展示,點(diǎn)擊事件如何處理等等,不可或缺;有 option 標(biāo)記的方法為可選實現(xiàn)的,相當(dāng)于是一些輔助設(shè)置和功能,就算沒有實現(xiàn)也有默認(rèn)值進(jìn)行處理。

HorizontalScroller 類里添加一個新的委托對象:

weak var delegate: HorizontalScrollerDelegate?

為了避免循環(huán)引用的問題,委托是 weak 類型。如果委托是 strong 類型的,當(dāng)前對象持有了委托的強(qiáng)引用,委托又持有了當(dāng)前對象的強(qiáng)引用,這樣誰都無法釋放就會導(dǎo)致內(nèi)存泄露。

委托是可選類型,所以很有可能當(dāng)前類的使用者并沒有指定委托。但是如果指定了委托,那么它一定會遵循 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)分別做了這些事情:

  • 定義一個常量,用來方便的改變布局?,F(xiàn)在默認(rèn)的是顯示的內(nèi)容長寬為100,間隔為10。
  • 創(chuàng)建一個 UIScrollView 作為容器。
  • 創(chuàng)建一個數(shù)組用來存放需要展示的數(shù)據(jù)

接下來實現(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)
}

上面的代碼做了如下工作:

  • 創(chuàng)建一個 UIScrollView 對象并且把它加到父視圖中。
  • 關(guān)閉 autoresizing masks ,從而可以使用 AutoLayout 進(jìn)行布局。
  • scrollview 添加約束。我們希望 scrollview 能填滿 HorizontalScroller
  • 創(chuàng)建一個點(diǎn)擊事件,檢測是否點(diǎn)擊到了專輯封面,如果確實點(diǎn)擊到了專輯封面,我們需要通知 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 作為一個參數(shù)傳了進(jìn)來,這樣就可以獲取點(diǎn)擊的具體坐標(biāo)了。

接下來我們調(diào)用了 numberOfViewsForHorizontalScroller 方法,HorizontalScroller 不知道自己的 delegate 具體是誰,但是知道它一定實現(xiàn)了 HorizontalScrollerDelegate 協(xié)議,所以可以放心的調(diào)用。

對于 scroll view 中的 view ,通過 CGRectContainsPoint 進(jìn)行點(diǎn)擊檢測,從而獲知是哪一個 view 被點(diǎn)擊了。當(dāng)找到了點(diǎn)擊的 view 的時候,則會調(diào)用委托方法里的 horizontalScrollerClickedViewAtIndex 方法通知委托。在跳出 for 循環(huán)之前,先把點(diǎn)擊到的 view 居中。

接下來我們再加個方法獲取數(shù)組里的 view :

func viewAtIndex(index :Int) -> UIView {
  return viewArray[index]
} 

這個方法很簡單,只是用來更方便獲取數(shù)組里的 view 而已。在后面實現(xiàn)高亮選中專輯的時候會用到這個方法。

添加如下代碼用來重新加載 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)
    }
  }
}

這個 reload 方法有點(diǎn)像是 UITableView 里面的 reloadData 方法,它會重新加載所有數(shù)據(jù)。

一段一段的看下上面的代碼:

  • 在調(diào)用 reload 之前,先檢查一下是否有委托。
  • 既然要清除專輯封面,那么也需要重新設(shè)置 viewArray ,要不然以前的數(shù)據(jù)會累加進(jìn)來。
  • 移除先前加入到 scrollview 的子視圖。
  • 所有的 view 都有一個偏移量,目前默認(rèn)是100,我們可以修改 VIEW_OFFSET 這個常量輕松的修改它。
  • HorizontalScroller 通過委托獲取對應(yīng)位置的 view 并且把它們放在對應(yīng)的位置上。
  • 把 view 存進(jìn) viewArray 以便后面的操作。
  • 當(dāng)所有 view 都安放好了,再設(shè)置一下 content size 這樣才可以進(jìn)行滑動。
  • HorizontalScroller 檢查一下委托是否實現(xiàn)了 initialViewIndex() 這個可選方法,這種檢查十分必要,因為這個委托方法是可選的,如果委托沒有實現(xiàn)這個方法則用0作為默認(rèn)值。最終設(shè)置 scroll view 將初始的 view 放置到居中的位置。

當(dāng)數(shù)據(jù)發(fā)生改變的時候,我們需要調(diào)用 reload 方法。當(dāng) HorizontalScroller 被加到其他頁面的時候也需要調(diào)用這個方法,我們在 HorizontalScroller.swift 里面加入如下代碼:

override func didMoveToSuperview() {
    reload()
}

在當(dāng)前 view 添加到其他 view 里的時候就會自動調(diào)用 didMoveToSuperview 方法,這樣可以在正確的時間重新加載數(shù)據(jù)。

HorizontalScroller 的最后一部分是用來確保當(dāng)前瀏覽的內(nèi)容時刻位于正中心的位置,為了實現(xiàn)這個功能我們需要在用戶滑動結(jié)束的時候做一些額外的計算和修正。

添加下面這個方法:

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))
    }  
}

上面的代碼計算了當(dāng)前視圖里中心位置距離多少,然后算出正確的居中坐標(biāo)并滑動到那個位置。最后一行是通知委托所選視圖已經(jīng)發(fā)生了改變。

為了檢測到用戶滑動的結(jié)束時間,我們還需要實現(xiàn) UIScrollViewDelegate 的方法。在文件結(jié)尾加上下面這個擴(kuò)展:

extension HorizontalScroller: UIScrollViewDelegate {
    func scrollViewDidEndDragging(scrollView: UIScrollView, willDecelerate decelerate: Bool) {
        if !decelerate {
            centerCurrentView()
        }
    }

    func scrollViewDidEndDecelerating(scrollView: UIScrollView) {
        centerCurrentView()
    }
}

當(dāng)用戶停止滑動的時候,scrollViewDidEndDragging(_:willDecelerate:) 這個方法會通知委托。如果滑動還沒有停止,decelerate 的值為 true 。當(dāng)滑動完全結(jié)束的時候,則會調(diào)用 scrollViewDidEndDecelerating 這個方法。在這兩種情況下,你都應(yīng)該把當(dāng)前的視圖居中,因為用戶的操作可能會改變當(dāng)前視圖。

你的 HorizontalScroller 已經(jīng)可以使用了!回頭看看前面寫的代碼,你會看到我們并沒有涉及什么 Album 或者 AlbumView 的代碼。這是極好的,因為這樣意味著這個 scroller 是完全獨(dú)立的,可以復(fù)用。

運(yùn)行一下你的項目,確保編譯通過。

這樣,我們的 HorizontalScroller 就完成了,接下來我們就要把它應(yīng)用到我們的項目里了。首先,打開 Main.Sstoryboard 文件,點(diǎn)擊上面的灰色矩形,設(shè)置 Class 為 HorizontalScroller :

http://wiki.jikexueyuan.com/project/ios-design-patterns-in-swift/images/5.png" alt="" />

接下來,在 assistant editor 模式下向 ViewController.swift 拖拽生成 outlet ,命名為 scroller :

http://wiki.jikexueyuan.com/project/ios-design-patterns-in-swift/images/6.png" alt="" />

接下來打開 ViewController.swift 文件,是時候?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)
    }
}

讓我們一行一行的看下這個委托的實現(xiàn):

  • 獲取上一個選中的相冊,然后取消高亮
  • 存儲當(dāng)前點(diǎn)擊的相冊封面
  • 獲取當(dāng)前選中的相冊,設(shè)置為高亮
  • 在 table view 里面展示新數(shù)據(jù)

接下來在擴(kuò)展里添加如下方法:

func numberOfViewsForHorizontalScroller(scroller: HorizontalScroller) -> (Int) {
    return allAlbums.count
}

這個委托方法返回 scroll vew 里面的視圖數(shù)量,因為是用來展示所有的專輯的封面,所以數(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)建了一個新的 AlbumView ,然后檢查一下是不是當(dāng)前選中的專輯,如果是則設(shè)為高亮,最后返回結(jié)果。

是的就是這么簡單!三個方法,完成了一個橫向滾動的瀏覽視圖。

我們還需要創(chuà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)
}

這個方法通過 LibraryAPI 加載專輯數(shù)據(jù),然后根據(jù) currentAlbumIndex 的值設(shè)置當(dāng)前視圖。在設(shè)置之前先進(jìn)行了校正,如果小于0則設(shè)置第一個專輯為展示的視圖,如果超出了范圍則設(shè)置最后一個專輯為展示的視圖。

接下來只需要指定委托就可以了,在 viewDidLoad 最后加入一下代碼:

scroller.delegate = self
reloadScroller()

因為 HorizontalScroller 是在 StoryBoard 里初始化的,所以我們需要做的只是指定委托,然后調(diào)用 reloadScroller() 方法,從而加載所有的子視圖并且展示專輯數(shù)據(jù)。

標(biāo)注:如果協(xié)議里的方法過多,可以考慮把它分解成幾個更小的協(xié)議。UITableViewDelegateUITableViewDataSource 就是很好的例子,它們都是 UITableView 的協(xié)議。嘗試去設(shè)計你自己的協(xié)議,讓每個協(xié)議都單獨(dú)負(fù)責(zé)一部分功能。

運(yùn)行一下當(dāng)前項目,看一下我們的新頁面:

http://wiki.jikexueyuan.com/project/ios-design-patterns-in-swift/images/7.png" alt="" />

等下,滾動視圖顯示出來了,但是專輯的封面怎么不見了?

啊哈,是的。我們還沒完成下載部分的代碼,我們需要添加下載圖片的方法。因為我們所有的訪問都是通過 LibraryAPI 實現(xiàn)的,所以很顯然我們下一步應(yīng)該去完善這個類了。不過在這之前,我們還需要考慮一些問題:

  • AlbumView 不應(yīng)該直接和 LibraryAPI 交互,我們不應(yīng)該把視圖的邏輯和業(yè)務(wù)邏輯混在一起。
  • 同樣, LibraryAPI 也不應(yīng)該知道 AlbumView 這個類。
  • 如果 AlbumView 要展示封面,LibraryAPI 需要告訴 AlbumView 圖片下載完成。

看起來好像很難的樣子?別絕望,接下來我們會用觀察者模式 (Observer Pattern) 解決這個問題!