建立一個像Medium App的下滑選單

建立一個像Medium App的下滑選單
建立一個像Medium App的下滑選單

當一個使用者按下選單按鈕,主畫面下滑揭示了選單。如下圖是在Medium App中使用到下滑選單的畫面。

slide-down-menu-sample

倘若你前面的章節有跟著一起進行,你應該對客製視圖控制器轉換有了基本的了解。本章,你將運用你所學到的來建造一個生動的下滑選單。

依照慣例,我想你不需要從頭建立專案,建議可以使用我們準備好的範例模板來開始,它包含了Storyboard 以及視圖控制器類別。你將會發現兩個視圖控制器。一個是主畫面(嵌入至導覽控制器中),而另一個導覽選單。倘若你執行專案,這個App應該會出現一個主畫面加上一些虛構的資料。 繼續往下進行之前,先花個幾分鐘瀏覽一下這個程式模板以熟悉一下專案內容。

以Modal 方式呈現選單

好的,我們開始吧。首先打開 Main.storyboard檔。你應該會找到兩個還沒有連上任何Segue的表格視圖控制器,為了在使用者按下選單按鈕時能帶出選單,按住control鍵不放,從選單按鈕拖曳至選單表格視圖控制器(menu table view controller)。將按鈕釋放,然後選取「present modally」作為Segue的動作。

slide-down-menu-segue

倘若你執行專案,這個選單將會以modal視圖的方式來呈現。為了關閉(unwind)選單,我們會加入unwind Segue。

打開NewsTableViewController.swift 檔並插入一個unwind動作方法:

@IBAction func unwindToHome(segue: UIStoryboardSegue) {
  let sourceController = segue.sourceViewController as MenuTableViewController
  self.title = sourceController.currentItem
}

現在回到Storyboard,按住control 鍵不放,從Menu table view controller的prototype cell 拖曳至exit 圖示。提示出現之後,在selection segue選擇unwindToHome: 選項。

slide-down-menu-unwind

現在當使用者按下任何選單項目,選單控制器便會解除,進而揭示主畫面。透過 unwindToHome: 動作方法,這個主視圖控制器(也就是NewsTableViewController)依照使用者在選單項目上的選取來變更其標題。為了簡單起見,我們只是變更導覽欄(navigation bar)的標題,而不會改變主畫面的內容。

另外,主視圖控制器會將所選取的項目以白色來標示,想讓App能如預期般順利運作前,還有幾個方法要先實作。

MenuTableViewController 類別插入以下的方法:

override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
  let menuTableViewController = segue.sourceViewController as MenuTableViewController

  if let selectedRow = menuTableViewController.tableView.indexPathForSelectedRow()?.row {
    currentItem = menuItems[selectedRow]
  }
}

這裏我們只是設定 currentItem 為選單選取的項目。

在NewsTableViewController.swift 檔,插入下面的方法來傳遞目前的標題至選單控制器:

override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
    let menuTableViewController = segue.destinationViewController as MenuTableViewController
    menuTableViewController.currentItem = self.title!
}

現在編譯與執行專案。按下選單項目,App便會以modal方式來呈現選單,當你選取選單項目,選單會關閉,導覽欄標題也會跟著變更。

slide-down-menu-1

建立生動的下滑選單

現在選單是使用標準動畫來呈現,我們來建立自訂轉換。如同我前面章節所述,客製視圖控制器動畫的核心,是動畫物件遵循了UIViewControllerAnimatedTransitioningUIViewControllerTransitioningDelegate 協定。我們準備實作這個類別。但是我們先來看下滑選單的運作方式。當使用者按下選單,主視圖開始下滑直到它到達預定的位置,也就是離畫面底部150點處。以下的圖片說明可以讓你對滑動選單更有概念。

slide-down-menu-explained

建立下滑選單動畫

想要建立下滑特效,我們會建立一個稱作MenuTransitionManager 的下滑動畫。在專案導覽器,按右鍵來建立一個新檔案。將類別取作MenuTransitionManager,並將其設為NSObject的子類別。

更新類別如下:

class MenuTransitionManager: NSObject, UIViewControllerAnimatedTransitioning, UIViewControllerTransitioningDelegate {
   var duration = 0.5
   var isPresenting = false

   var snapshot:UIView?

   func transitionDuration(transitionContext: UIViewControllerContextTransitioning) -> NSTimeInterval {
      return duration
   }

   func animateTransition(transitionContext: UIViewControllerContextTransitioning) {

      // 取得我們的 fromView、toView 以及 container view的參照
      let fromView = transitionContext.viewForKey(UITransitionContextFromViewKey)!
      let toView = transitionContext.viewForKey(UITransitionContextToViewKey)!

      // 設定滑動的變換(transform)
      let container = transitionContext.containerView()
      let moveDown = CGAffineTransformMakeTranslation(0, container.frame.height - 150)
      let moveUp = CGAffineTransformMakeTranslation(0, -50)

      //  將兩個視圖加進容器視圖
      if isPresenting {
         toView.transform = moveUp
         snapshot = fromView.snapshotViewAfterScreenUpdates(true)
         container.addSubview(toView)

         container.addSubview(snapshot!)
      }

      // 執行動畫
      UIView.animateWithDuration(duration, delay: 0.0, usingSpringWithDamping: 0.9, initialSpringVelocity: 0.3, options: nil, animations: {

        if self.isPresenting {
           self.snapshot?.transform = moveDown
           toView.transform = CGAffineTransformIdentity
        } else {
           self.snapshot?.transform = CGAffineTransformIdentity
           fromView.transform = moveUp
        }

     }, completion: { finished in

        transitionContext.completeTransition(true)
        if !self.isPresenting {
            self.snapshot?.removeFromSuperview()
        }
     })
   }

   func animationControllerForDismissedController(dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {

      isPresenting = false
      return self
   }

   func animationControllerForPresentedController(presented: UIViewController, presentingController presenting: UIViewController, sourceController source: UIViewController) -> UIViewControllerAnimatedTransitioning? {

     isPresenting = true
     return self
   }
}

這個類別實作UIViewControllerAnimatedTransitioningUIViewControllerTransitioningDelegate 協定。我不準備解釋這個方法的細節,因為前面章節已經解釋過了。我們將重點放在動畫block(也就是animateTransition 方法)。
參考前面圖片說明,在轉換期間主視圖是 fromView,此時選單視圖是toView

要建立動畫,我們設置兩個變換。第一個變換(也就是 moveDown)是用來下移主視圖。第二個變換(也就是 moveUp)是設定將選單視圖上移一點,所以當它回復到原來位置時,它會有下滑效果。稍後你執行專案,你將會瞭解我的意思。

從iOS 7 開始,你可以使用UIView-Snapshotting API 來快速且輕易的建立一個輕量級(light-weight)的視圖快照(snapshot)。

snapshot = fromView.snapshotViewAfterScreenUpdates(true)

透過snapshotViewAfterScreenUpdates方法的呼叫,你有一個主視圖的快照。有了快照,我們可以將其加入容器視圖來執行動畫。注意這個快照是加到選單視圖的上方。

選單呈現的實際動畫,其實作是非常簡單的。我們只是對主視圖的快照實施moveDown 變換,並將選單視圖回復其預設位置。

self.snapshot?.transform = moveDown
toView.transform = CGAffineTransformIdentity

當選單解除時,逆向效果便開始發生。主視圖的快照向上滑動並回到其預設位置。另外快照會從它的父視圖被移除,也因此我們可以將實際主視圖帶回。

現在打開NewsTableViewController.swift 並宣告一個變數給MenuTransitionManager 物件:

var menuTransitionManager = MenuTransitionManager()

prepareForSegue 方法,加上一行變數來串接動畫:

override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
  let menuTableViewController = segue.destinationViewController as MenuTableViewController
  menuTableViewController.currentItem = self.title!
  menuTableViewController.transitioningDelegate = self.menuTransitionManager
}

就這樣!你現在可以編譯與執行專案。按下選單按鈕,你將會得到一個下滑選單。

slide-down-menu-output2

偵測按下手勢

現在唯一關掉選單的方式就是選取選單項目。從使用者的觀點,按下快照應該也要能關掉選單。不過,主視圖的快照沒有反應。

這個快照實際上是一個UIView物件。因此我們可以建立一個UITapGestureRecognizer 物件並將其加至快照。

實體化一個UITapGestureRecognizer物件,我們需要傳給它一個目標物件(target object),也就是接收由接收者所發送的動作訊息,且會呼叫動作方法。

很明顯地,你可以將特定物件寫進去,作為目標物件來解除視圖。但是為了讓設計有所彈性,我們會定義一個協定讓代理物件來執行它。

在MenuTransitionManager.swift,定義下面的協定:

@objc protocol MenuTransitionManagerDelegate {
  func dismiss()
}

這裏我們以必要實作做的方法來定義一個MenuTransitionManagerDelegate 協定。這個代理應該要實作「dismiss」方法,並提供解除或關閉視圖的實際邏輯。

註:這裏的協定必須要公開為使用Objective-C運行 ,因為它會被UITapGestureRecognizer 存取。這也是為何協定的字首要加上 @obj的原因。

MenuTransitionManager 類別,宣告代理變數:

var delegate:MenuTransitionManagerDelegate?

稍後要負責處理按下手勢的物件,應該設為代理物件。

最後,我們需要建立一個UITapGestureRecognizer 物件並將其加入快照中。一個比較好的方式是在snapshot變數中定義一個 didSet 方法。變更snapshot宣告如下:

var snapshot:UIView? {
   didSet {
     if let _delegate = delegate {
         let tapGestureRecognizer = UITapGestureRecognizer(target: _delegate, action: "dismiss")
         snapshot?.addGestureRecognizer(tapGestureRecognizer)
     }
   }
}

屬性觀察者(Property observer)是Swift中一個強大的功能。每次屬性的值設定後會呼叫觀察者(willSet/didSet)。這提供了在指派之前或之後,立即執行某個動作的便利方式。willSet 方法在值被儲存前會被呼叫,而 didSet方法在指派後會立即呼叫。

在以上的程式中,我們使用屬性觀察者來建立一個手勢辨識器(gesture recognizer)並設定給快照。所以在每次指派一個物件給snapshot變數時,它會立即設置按下手勢辨識器(tap gesture recognizer)。

我們幾乎已經完成了。現在回到NewsTableViewController.swift,也就是要實作MenuTransitionManagerDelegate 協定的類別。

首先,變更類別宣告如下:

class NewsTableViewController: UITableViewController, MenuTransitionManagerDelegate

接下來,實作協定所需的方法:

func dismiss() {
    dismissViewControllerAnimated(true, completion: nil)
}

這裏我們只是透過dismissViewControllerAnimated 方法的呼叫來解除視圖控制器。

最後,在NewsTableViewController類別的prepareForSegue 方法中插入下面一行程式,來設定它自己為代理物件:

self.menuTransitionManager.delegate = self

很棒!你已經準備好再次測試App了,按下Run按鈕來測試。你應該能夠在按下主視圖的快照後解除選單。

如果將自訂的視圖控制器轉換運用得宜,它能夠很棒的改善使用者體驗,讓你的App與眾不同。下滑選單只是一個例子。試著在你的下一個建立你自己的動畫。

為了進一步讓您參考,你可以在這裡下載完整的 Xcode 專案

譯者簡介:王豪勳 -渥合數位服務創辦人,畢業於台灣大學應用力學研究所,曾在半導體產業服務多年,近年來專注於協助客戶進行App軟體以及網站開發,平常致力於研究各式最軟硬體技術,擁有多本譯作。

本文摘自提升iOS 8 App程式設計進階實力的30項關鍵技巧。如有興趣了解其他技巧,可到博客來天瓏購買此書。

作者
Simon Ng
軟體工程師,AppCoda 創辦人。著有《iOS 17 App 程式設計實戰心法》、《iOS 17 App程式設計進階攻略》以及《精通SwiftUI》。曾任職於HSBC, FedEx等跨國企業,專責軟體開發、系統設計。2012年創立AppCoda技術部落格,定期發表iOS程式教學文章。現時專注發展AppCoda業務,致力於iOS程式教學、產品設計及開發。你可以到推特與我聯絡。
評論
很好! 你已成功註冊。
歡迎回來! 你已成功登入。
你已成功訂閱 AppCoda 中文版 電子報。
你的連結已失效。
成功! 請檢查你的電子郵件以獲取用於登入的連結。
好! 你的付費資料已更新。
你的付費方式並未更新。