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

適配器模式 - Adapter

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

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

如何使用適配器模式

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

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

新建一個(gè) Swift 文件:HorizontalScroller.swift ,作為我們的橫滑滾動(dòng)控件, HorizontalScroller 繼承自 UIView 。

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

@objc protocol HorizontalScrollerDelegate {
}

這行代碼定義了一個(gè)新的協(xié)議: HorizontalScrollerDelegate 。我們?cè)谇懊婕由狭?@objc 的標(biāo)記,這樣我們就可以像在 objc 里一樣使用 @optional 的委托方法了。

接下來我們?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)的,一般來說包括那些用來顯示的必須數(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)引用,這樣誰都無法釋放就會(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)分別做了這些事情:

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

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

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

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

接下來我們調(diào)用了 numberOfViewsForHorizontalScroller 方法,HorizontalScroller 不知道自己的 delegate 具體是誰,但是知道它一定實(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 居中。

接下來我們?cè)偌觽€(gè)方法獲取數(shù)組里的 view :

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

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

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

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

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

當(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 的最后一部分是用來確保當(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 就完成了,接下來我們就要把它應(yīng)用到我們的項(xià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 文件,是時(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):

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

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

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

這個(gè)委托方法返回 scroll vew 里面的視圖數(shù)量,因?yàn)槭怯脕碚故舅械膶]嫷姆饷?,所以?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è)專輯為展示的視圖。

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

scroller.delegate = self
reloadScroller()

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

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

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

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

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

啊哈,是的。我們還沒完成下載部分的代碼,我們需要添加下載圖片的方法。因?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 圖片下載完成。

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