如何在 iOS 中實現一個可展開的 Table View

原文鏈接=http://www.appcoda.com/expandable-table-view/
作者=gabriel
原文日期=2015/11/16
譯者=pmst
校對=numbbbbb
定稿=numbbbbb
歡迎加入swift技術群(392436022)共同學習進步!

已授權SwiftGG轉載,我們是一群熱愛翻譯并且熱愛 Swift 的人,希望通過自己的努力讓不懂英語的程序員也能第一時間學習到國外的先進技術。

正文

幾乎所有的應用程序都有一個共同的特點:允許用戶在多個視圖控制器之間導航和協同工作。這些視圖控制器應用非常廣泛,例如簡單顯示某些形式的信息到屏幕上,或從用戶處收集復雜的輸入數據。為了實現一款應用的不同功能,經常需要創建新的視圖控制器,且多數任務比較艱巨。不過,倘若你利用expandable tableviews(之后統一譯為可展開的 tableview ) ,我們就能避免創建新的視圖控制器(以及相關的界面和 storyboard)。

顧名思義,可展開的 tableview “允許”其單元格展開和折疊,顯示和隱藏那些始終可見的單元格下的其他單元格。當需要收集簡單數據或向用戶顯示請求信息時,創建可展開的 tableview 是一個不錯的選擇。通過這種方式,我們無需再創建新的視圖控制器,只需給定幾種選項供用戶抉擇(只能選其一)。例如,利用可展開的 tableview ,你可以顯示和隱藏用于收集數據的表格選項,而不再需要其他額外的視圖控制器。

是否應該使用可展開的 tableview 取決于你所開發的應用程序的性質。應用程序的外觀和體驗通常來說不需要考慮,我們可以繼承 UITableViewCell 并自定義單元格的 UI,還可以創建額外的 xib 文件。總之,它僅僅和需求有關。

本教程中,我將向你展示一種簡單但實用的可展開 tableview 創建方式。注意,實現 tableview 展開功能并不是只有本文介紹的這種方法。大部分實現都要考慮應用的具體需求,但我旨在提供一個相對通用的可以在大多數情況下重用的方法。好了,下面我們來看看本文要實現什么應用。

關于演示應用

我們將看到如何創建并使用一個可展開的 tableview ,我們會用一個包含 tableview 的視圖控制器來實現整個應用。首先,我們來制作一個表單供用戶輸入數據,該 tableview 包含以下三個部分:

  1. 個人信息( Personal )
  2. 愛好( Preferences )
  3. 工作經驗( Work Experience )

每個 section 包含一些可展開的單元格,用于觸發顯示或隱藏當前 section 中其他單元格。每個 section 的頂級單元格(用于展開和折疊其他單元格)具體描述如下:

“Personal” section 內容如下:

  1. Full name:顯示用戶的全名,當點擊展開時,顯示兩個可用的子單元格用于鍵入 first name 以及 last name。
  2. Data of birth:顯示用戶的出生日期。當展開該單元格時,提供一個日期選擇視圖(date pickerview)供用戶選擇日期,以及一個提交按鈕將所選日期顯示到對應的頂級單元格中。
  3. Martial status:顯示用戶是已婚還是單身。展開時,提供一個開關控件(switch control)用于設置用戶婚姻狀態。

“Preferences” section 內容如下:

  1. Favorite sport:我們的表單還應要求用戶選擇最喜歡的運動,選中后顯示在該單元格中。當該單元格呈展開狀態時,出現四個運動條目可供選擇,當其中一個子條目選中后,單元格自動折疊。
  2. Favorite color:基本和上面一致,這里我們將顯示三個不同的顏色條目供用戶選擇。

“Work Experience” section 內容如下:

  1. Level:當點擊展開這個頂級單元格時,顯示另外一個包含滑動控件(slider control)的單元格,要求用戶指定一個大概的工作經驗水平。值的范圍限定在 [0,10] 之間,以整型數據保存。

下面的動畫圖形展示了我們將要實現的內容:

gif

上面的動畫中可以看到 tableview 展開時顯示了各式各樣的單元格。所有這些都能在初始項目中找到,項目中已經預先做好了一些準備工作。所有自定義單元格均采用 xib 文件設計,指定它們的 Custom Class 為自定義 CustomCell 類,繼承自 UITableViewCell:

img1

項目中你可以找到以下單元格的 xib 文件:

img2

它們的文件名已經表明了每一個單元格的用途,你也可以對它們做深入探究。

除了單元格之外,你還可以找到一些已經實現的代碼。盡管它們非常重要,完成了演示應用程序的功能,但是那些代碼并不包含本教程的核心部分,所以我選擇直接跳過,只是提供實現代碼。教程中我們感興趣的代碼將隨著章節學習逐步添加進來。

好了,現在你已經知道我們的最終目標是什么了,是時候去創建一個可展開的 tableview 了。

描述單元格

本教程中,我向你展示的所有有關可展開 tableview 的實現和技術都遵循一個單一和簡單的思想:描述應用中每個單元格的細節。通過這種方式,你就可以知曉哪些單元格是可展開的、哪些是可見的、每一個單元格中的標簽值是什么等等。確切來說,整體思想如下:為每一個單元格分配一組描述信息、描述屬性或特定的值,接著向應用提供這些描述來正確顯示每一個單元格。

對于這個演示應用程序,我創建和使用的所有屬性都顯示在下面列表中。注意,你可以新增屬性,也可以修改現有項。不管怎樣,最重要的是你能統籌全局,這樣你才能夠執行所有你需要的改動。屬性列表如下:

  • isExpandable:這是一個布爾類型值,表明單元格是否允許被展開。它在本教程中是一個相當重要的屬性值。
  • isExpanded:依舊是一個布爾類型值,指示一個可展開的單元格的當前狀態(展開或折疊)。頂級單元格默認是折疊的,因此所有頂級單元格的初始值均將設置為NO
  • isVisible:顧名思義,指示單元格是否可見。它將在之后起到舉足輕重的作用,我們將根據該屬性在 tableview 中顯示合適的單元格。
  • value:這個屬性對于保存 UI 控件的值(例如婚姻狀況中的switch控件的狀態值)相當有用。不是所有的單元格都有這樣的控件,所以它們中的絕大部分的 value 屬性值為空。
  • primaryTitle:用于顯示單元格主標題標簽(main title label)中的文本內容,還包含一些應該顯示在單元格中的實際值。
  • secondaryTitle:用于顯示單元格子標題標簽(subtitle lable)或二級標簽的文本內容,
  • cellIdentifier:自定義單元格的標識符所匹配的當前描述。通過使用 cellIdentifier,應用程序不僅能夠出列合適的單元格(tableview 中的 dequeue 方法),而且可以根據顯示的單元格來確定應該執行的 action ,以及指定每個單元格的高度。
  • additionalRows:它包含的附加行總數,即那些當單元格展開式需要顯示的額外行數。

我們將使用上文介紹的屬性集合來描述 tableview 中的每一個單元格。在應用層面我們只需一個屬性列表(plist)文件即可實現,簡單易用。在 plist 文件中,我們將為所有單元格正確地填充上述屬性的值,這樣從應用角度來說,我們最終只要一份完整的技術描述,無需編寫一行代碼。這是不是灰常棒呢?

通常來說,我們會在項目中創建一個新的屬性列表文件,接著開始往里面填充適當的數據。但這里無需自己動手,我已經為你提供了.plist文件。所以,你只需下載并將它添加到啟動項目即可。為所有單元格設置屬性非常麻煩并且毫無意義,那些填充缺省值的復制粘貼行為只可能會讓你感覺疲勞和枯燥。不過,我們還是需要介紹一下 plist 文件內容:

首先,你下載的文件名應該為 CellDescriptor.plist(希望沒有錯)。基礎結構(請見下圖中的 Root 鍵名)是一個數組,其中每個條目項分別對應 tableview 中所呈現的 section。這意味著 plist 文件包含三個條目項,和 tableview 中顯示的 section 數目保持一致。

每個 section 中包含的條目項同樣是一個數組(類型為字典),分別用于描述當前 section 中的每一個單元格。實際上,我們采用字典形式對上述屬性進行分組,每一個字典匹配一個單獨的單元格描述。下面是屬性列表文件的一個示例:

img3

現在是最佳時機,抽點時間出來,透徹地理解下所有我們將要顯示到 tableview 中的單元格描述屬性以及相關值。顯然,通過使用單元格描述,能夠幫助我們明顯減少創建和管理可展開單元格的代碼,此外我們無需告知應用關于這些單元格的狀態(例如,哪些單元格是可擴展的,它是否允許特定單元格進行展開,在代碼中確定單元格是否可見等等這些問題)。所有這些信息已經存儲在你剛剛下載的屬性列表文件之中。

加載單元格描述

終于可以開始編寫代碼了,盡管我們描述單元格的方式(即 plist 文件)節省了大量時間,但依舊需要向項目中添加代碼。現在單元格的描述屬性列表文件已經處于項目之中,我們首先要做的就是以編程方式把它的內容加載到一個數組中。這個數組將在下一小節作為 tableview 的數據源(datasource)。

首先,請打開項目中 ViewController.swift 文件,在類頂部聲明如下屬性:

var cellDescriptors: NSMutableArray!

該數組將包含所有單元格字典類型的描述,從屬性列表文件加載得到。

接著,讓我們實現一個自定義函數,用于實現加載文件內容到數組中。我們為該函數命名為 loadCellDescription()

func loadCellDescriptors() {
    if let path = NSBundle.mainBundle().pathForResource("CellDescriptor", ofType: "plist") {
        cellDescriptors = NSMutableArray(contentsOfFile: path)
    }
}

我們這里的實現方法相當簡單:首先我們確保屬性列表文件在 bundle 中的路徑是有效的,接著我們加載文件內容并初始化 cellDescriptors 數組。

下一步我們將調用上述方法,在視圖將要顯示之前、tableview 配置之后調用函數(我們希望先對 tableview 進行配置,然后在它上面顯示數據)。

override func viewWillAppear(animated: Bool) {
    super.viewWillAppear(animated)
    // 先配置tableview
    configureTableView()
    
    // 后加載數據
    loadCellDescriptors()
}

如果你在上面代碼最后一行鍵入print(cellDescriptors)命令,運行應用,你將看到命令控制臺處打印了 plist 文件的所有內容。這意味著它們已經成功被加載到內存中了。

img4

按照慣例,我們本節的任務應該到此結束,但恰恰相反;我們將繼續下去,接下來的部分至關重要。到目前為止,你已經發現(特別是打印 CellDescriptor.plist 文件內容之后),當應用程序啟動之后并不是所有單元格都是可見的(譯者注: plist 文件中單元格的 Visible 屬性,有些為 YES,有些則為 NO)。實際上,我們不能知曉它們究竟是否將同時可見,因為只有當每次用戶要求時,它們才進行展開或折疊。

從編程角度來說,這意味著每個單元格的行索引值(row index)不允許為常量(一般我們處理單元格時,都喜歡使用IndexPath.row這種編程方式),所以我們不能通過單元格行號遍歷數據源數組(cellDescriptors)并顯示單元格。解決方式如下:僅提供可見的單元格行索引值。任何嘗試顯示描述中標記為不可見的單元格都會出錯,當然還會導致其他異常應用行為。

所以,為此我們將要實現一個新函數getIndicesOfVisibleRows()。它的名字已經說明了它的作用: 它僅獲取那些已經標記為可見的單元格。在我們繼續執行之前,請再次回到類的頂部,新增如下聲明:

var visibleRowsPerSection = [[Int]]()

該二維數組將用于存儲每個 section 中可見的單元格行索引值(一維用作 section,另一維用作 rows)。

現在,讓我們來看新函數的實現。你可能已經猜到,我們將檢查所有單元格的描述信息,接著將那些“isVisible”屬性值為YES的單元格索引值添加到二維數組中。很顯然,我們不得不通過一個嵌套循環來處理,但是它用起來不難。這里是函數實現:

func getIndicesOfVisibleRows() {
    visibleRowsPerSection.removeAll()
    
    // 遍歷單元格描述數組
    for currentSectionCells in cellDescriptors {
        // 暫存每個 section 中,isVisible = true 的行號
        var visibleRows = [Int]()
 
        for row in 0...((currentSectionCells as! [[String: AnyObject]]).count - 1) {
            // 檢查每個單元格的isVisible屬性是否為true
            if currentSectionCells[row]["isVisible"] as! Bool == true {
                visibleRows.append(row)
            }
        }
        // 將所有標記為可見的單元格行號保存到該數組中
        // 首次加載描述文件后 該數組值為 [[0, 3, 5], [0, 5], [0]]
        visibleRowsPerSection.append(visibleRows)
    }
}

請注意,函數一開始需要清空visibleRowsPerSection數組中之前的所有內容,否則后續調用該函數我們將最終得到錯誤的數據。除此之外,實現方式非常簡單,所以我不會過多介紹細節。

首次調用上述函數位置應該在從文件加載單元格描述信息操作之后(我們將在之后再次調用它)。因此,重新審視我們在這一部分中實現的第一個函數,我們修改如下:

func loadCellDescriptors() {
    if let path = NSBundle.mainBundle().pathForResource("CellDescriptor", ofType: "plist") {
        cellDescriptors = NSMutableArray(contentsOfFile: path)
        getIndicesOfVisibleRows()
        tblExpandable.reloadData()
    }
}

盡管 tableview 目前還不能正常使用(要知道還未實現 Datasource 方法!),但我們提前調用reloadData()進行 tableview 重載,確保應用程序啟動后,能夠正確顯示單元格內容。

顯示單元格

別忘了每一次應用程序啟動時都要加載單元格描述,下面我們準備處理和顯示這些單元格。本小節中,我們首先創建另一個新函數,在 cellDescriptors 數組中查找并返回適當的單元格描述信息。如你即將在下面代碼片段中看到的一樣,從 visibleRowsPerSection 數組中獲取數據(即可見行的索引值)是新函數工作的先決條件。

func getCellDescriptorForIndexPath(indexPath: NSIndexPath) -> [String: AnyObject] {
    // 步驟一:
    let indexOfVisibleRow = visibleRowsPerSection[indexPath.section][indexPath.row]
    // 步驟二:
    let cellDescriptor = cellDescriptors[indexPath.section][indexOfVisibleRow] as! [String: AnyObject]
    return cellDescriptor
}

上述函數接受某個單元格的路徑索引值(NSIndexPath),且該單元格此刻是 tableview 的處理項;函數返回值為一個字典,包含匹配單元格的所有屬性。函數內部實現的首要任務在給定路徑索引值(即 index path)的條件下,找到匹配的可見行的索引值,這一步很簡單,只需要傳入每個單元格的 section 和 row 即可(請見步驟一)。到目前為止,我們還未接觸到 tableview 的代理方法,對上述內容也一知半解,但是我可以提前給你打個“預防針”:每個 section 的 row 總數將與每個 section 中的可見單元格數目保持一致。這意味著,上述實現中任意一個 indexPath.row 值(譯者注:section是固定的),在 visibleRowsPerSection 數組中都能找到一個可見單元格的索引值與之匹配。

通過每個單元格的行索引值,我們可以從 cellDescriptors 數組中“提取”到單元格描述信息(字典類型)。請注意提取過程中,數組的第二個維度值為 indexOfVisibleRow,而不是 indexPath.row。倘若使用第二個將導致返回錯誤數據。

我們再次構建了一個非常有用的函數,事實證明在之后的開發中非常好用。現在我們開始實現 viewController 類中的已存在的 tableview 方法。首先,我們需要指定 tableview 的 section 數量。

func numberOfSectionsInTableView(tableView: UITableView) -> Int {
    if cellDescriptors != nil {
        return cellDescriptors.count
    }
    else {
        return 0
    }
}

你要知道我們不能忽視 cellDescriptors 數組為nil的情況。當數組已經初始化完畢且填充了單元格描述信息,我們返回數組的元素個數。

接著,我們指定每個 section 的行數。正如我之前所說的,行數和可見單元格數量保持一致,所以我們可以僅用一行代碼返回該信息。

func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return visibleRowsPerSection[section].count
}

之后,確定 tableview 中每個 section 的標題:

func tableView(tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
    switch section {
    case 0:
        return "Personal"
 
    case 1:
        return "Preferences"
 
    default:
        return "Work Experience"
    }
}

接著,是時候指定每一行的高度了:

func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
    let currentCellDescriptor = getCellDescriptorForIndexPath(indexPath)
 
    switch currentCellDescriptor["cellIdentifier"] as! String {
    case "idCellNormal":
        return 60.0
    case "idCellDatePicker":
        return 270.0
    default:
        return 44.0
    }
}

這里我需要強調一些東西:這部分中我們首次調用早前實現的 getCellDescriptorForIndexPath:函數。我們需要獲得正確的單元格描述信息,緊接著有必要取得“cellIdentifier”屬性,只有依靠它的值才能指定行高。你可以在每個 xib 文件中檢查每種類型的單元格行高(就是如下所示的行高)。

最后是顯示實際的單元格。起初,每個單元格必須被 dequeued:

func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
    let currentCellDescriptor = getCellDescriptorForIndexPath(indexPath)
 
    // 每個單元格都是通過出列得到
    let cell = tableView.dequeueReusableCellWithIdentifier(currentCellDescriptor["cellIdentifier"] as! String, forIndexPath: indexPath) as! CustomCell
 
    return cell
}

再次,我們傳入當前路徑索引值獲得正確的單元格描述。通過使用"cellIdentifier"屬性出列一個正確的單元格,這樣我們能夠對每個單元格的特殊處理作進一步的深入探討(譯者注:說白了就是根據 cellIdentifier 標識符對單元格做分支處理)。

func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
    let currentCellDescriptor = getCellDescriptorForIndexPath(indexPath)
    let cell = tableView.dequeueReusableCellWithIdentifier(currentCellDescriptor["cellIdentifier"] as! String, forIndexPath: indexPath) as! CustomCell
 
    if currentCellDescriptor["cellIdentifier"] as! String == "idCellNormal" {
        if let primaryTitle = currentCellDescriptor["primaryTitle"] {
            cell.textLabel?.text = primaryTitle as? String
        }
 
        if let secondaryTitle = currentCellDescriptor["secondaryTitle"] {
            cell.detailTextLabel?.text = secondaryTitle as? String
        }
    }
    else if currentCellDescriptor["cellIdentifier"] as! String == "idCellTextfield" {
        cell.textField.placeholder = currentCellDescriptor["primaryTitle"] as? String
    }
    else if currentCellDescriptor["cellIdentifier"] as! String == "idCellSwitch" {
        cell.lblSwitchLabel.text = currentCellDescriptor["primaryTitle"] as? String
 
        let value = currentCellDescriptor["value"] as? String
        cell.swMaritalStatus.on = (value == "true") ? true : false
    }
    else if currentCellDescriptor["cellIdentifier"] as! String == "idCellValuePicker" {
        cell.textLabel?.text = currentCellDescriptor["primaryTitle"] as? String
    }
    else if currentCellDescriptor["cellIdentifier"] as! String == "idCellSlider" {
        let value = currentCellDescriptor["value"] as! String
        cell.slExperienceLevel.value = (value as NSString).floatValue
    }
 
    return cell
}

對于普通的單元格來說,我們僅需要設置 textLabel 標簽的文本值為 primaryTitle,以及設置 detailTextLabel 標簽的文本值為 secondaryTitle即可。在我們的演示應用中,使用 idCellNormal 標示符的單元格實際上就是頂級單元格( top-level cells),點擊可展開和折疊內容。

對于那些包含 textfiled 的單元格,我們僅需將它的占位符值(placeholder value)設置為單元格描述信息中的 primaryTitle 即可。

對于那些包含 switch 控件的單元格,我們需要做兩件事:首先指定 switch 控件前面的顯示文本內容(示例中是常量,你可以通過修改 CellDescriptor.plist 文件改變它),其次我們需要為 switch 控件設置合適的狀態,根據描述信息來決定“on”還是“off”。注意之后我們將有可能改變該值。

這里還有一些標識符為“idCellValuePicker”的單元格,這些單元格旨在提供一個選項列表。當點擊選中某個選項時,父單元格會自動折疊當前內容。此時父單元格的文本標簽值設置為選中值。

最后,有單元格包含了 slider 控件。這里我們從 currentCellDescriptor 字典中獲取到當前值,將其轉換為 float 類型的數字,再賦值給 slider 控件,這樣它在可視情況下總能呈現正確的值。稍后我們會改變這個值,以及更新相應的單元格描述。

而那些沒有添加上述幾種情況標識符的單元格,在本演示應用中不會起任何作用。但是,倘若你想以不同的方式處理它們,可以隨意修改代碼并添加任何缺失的部分。

現在你可以運行應用,看看目前的成果。期望不要過高,因為你僅僅看到的只是頂級單元格內容。別忘了我們還未啟用展開功能,所以當你點擊它們時什么都不會出現。然而,不要氣餒,正如你所看到的,到目前為止我們一切進展順利。

展開和折疊

我猜想本節內容你可能期盼已久了,畢竟這是本教程實際目的所在。下面我們將通過每次點擊頂級單元格控制展開和折疊,以及按要求顯示或隱藏正確的子單元格。

首先,我們需要知道點擊行的索引值(記住,不是實際的 indexPath.row,而是可見單元格中的行索引值),我們會首先將它分配給一個局部變量,如下 tableview 代理方法中所示:

func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
    let indexOfTappedRow = visibleRowsPerSection[indexPath.section][indexPath.row]
}

雖然實現單元格展開和折疊的代碼量不大,但是我們還是會逐步深入,這樣你能理解每個步驟的作用。現在我們獲取到了點擊行的實際索引值,我們必須檢查 cellDescriptors 數組中該單元格是否允許展開。如果它允許展開,且當前處于折疊狀態時,我們將指示(我們將使用一個 flag 標志位)這個單元格必須展開,反之這個單元格必須折疊:

func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
    let indexOfTappedRow = visibleRowsPerSection[indexPath.section][indexPath.row]
 
    if cellDescriptors[indexPath.section][indexOfTappedRow]["isExpandable"] as! Bool == true {
        var shouldExpandAndShowSubRows = false
        if cellDescriptors[indexPath.section][indexOfTappedRow]["isExpanded"] as! Bool == false {
            // In this case the cell should expand.
            shouldExpandAndShowSubRows = true
        }
    }
}

一旦上面的 flag 標志位設置為相應值,指示當前單元格的展開狀態,這時候我們有責任將標志位值保存到單元格描述集合中,即更新 cellDescriptors 數組。我們要為選中的單元格更新 “isExpanded” 屬性
,這樣在隨后的點擊中它都能正常運行(當它處于展開時點擊折疊,當折疊時點擊展開)。

func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
    let indexOfTappedRow = visibleRowsPerSection[indexPath.section][indexPath.row]
 
    if cellDescriptors[indexPath.section][indexOfTappedRow]["isExpandable"] as! Bool == true {
        var shouldExpandAndShowSubRows = false
        if cellDescriptors[indexPath.section][indexOfTappedRow]["isExpanded"] as! Bool == false {
            shouldExpandAndShowSubRows = true
        }
 
        cellDescriptors[indexPath.section][indexOfTappedRow].setValue(shouldExpandAndShowSubRows, forKey: "isExpanded")
    }
}

此刻,這里還有一個相當重要的細節不容我們忽視:如果你還記得,前文中指定了一個名為“isVisible”的屬性表明單元格的顯示狀態,就存在于單元格的描述中。該屬性必須隨著上文 flag 值改變而改變,所以當單元格展開時,顯示其他附加的不可見行,反之當單元格折疊時,隱藏那些附加行。實際上,通過更改該屬性的值我們實現了單元格展開和折疊的效果。所以一旦點擊了頂級單元格,需要立即更新附加單元格的信息,以下是修改后的代碼片段:

func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
    let indexOfTappedRow = visibleRowsPerSection[indexPath.section][indexPath.row]
 
    if cellDescriptors[indexPath.section][indexOfTappedRow]["isExpandable"] as! Bool == true {
        var shouldExpandAndShowSubRows = false
        if cellDescriptors[indexPath.section][indexOfTappedRow]["isExpanded"] as! Bool == false {
            shouldExpandAndShowSubRows = true
        }
 
        cellDescriptors[indexPath.section][indexOfTappedRow].setValue(shouldExpandAndShowSubRows, forKey: "isExpanded")
 
        for i in (indexOfTappedRow + 1)...(indexOfTappedRow + (cellDescriptors[indexPath.section][indexOfTappedRow]["additionalRows"] as! Int)) {
            cellDescriptors[indexPath.section][i].setValue(shouldExpandAndShowSubRows, forKey: "isVisible")
        }
    }
}

我們距離追尋已久的功能實現僅一步之遙,但是我們首先必須關注一個更重要的事情:在上面代碼片段中,我們僅改變了一些單元格的“isVisible”屬性值,這意味著所有可見行的總數也隨之改變了。所以,在我們重載 tableview 之前,我們必須重新向應用詢問可見行的索引值:

func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
    let indexOfTappedRow = visibleRowsPerSection[indexPath.section][indexPath.row]
 
    if cellDescriptors[indexPath.section][indexOfTappedRow]["isExpandable"] as! Bool == true {
        var shouldExpandAndShowSubRows = false
        if cellDescriptors[indexPath.section][indexOfTappedRow]["isExpanded"] as! Bool == false {
            shouldExpandAndShowSubRows = true
        }
 
        cellDescriptors[indexPath.section][indexOfTappedRow].setValue(shouldExpandAndShowSubRows, forKey: "isExpanded")
 
        for i in (indexOfTappedRow + 1)...(indexOfTappedRow + (cellDescriptors[indexPath.section][indexOfTappedRow]["additionalRows"] as! Int)) {
            cellDescriptors[indexPath.section][i].setValue(shouldExpandAndShowSubRows, forKey: "isVisible")
        }
    }
 
    getIndicesOfVisibleRows()
    tblExpandable.reloadSections(NSIndexSet(index: indexPath.section), withRowAnimation: UITableViewRowAnimation.Fade)
}

正如你看見的那樣,我僅對屬于點擊單元格的 section 部分進行動畫重載,倘若你不喜歡這種方式的話,可以自己來實現。

現在快啟動應用試試。點擊頂級單元格進行展開和折疊,和子單元格互動下,盡管啥都不會發生,但是結果看起來相當棒!

取值

從現在開始,我們將把注意力完全集中在處理數據輸入以及用戶與子單元格內的控件的交互上。首先我們將為那些標識符為 “idCellValuePicker” 的單元格實現邏輯事務,處理點擊事件。在我們的演示應用中,這些單元格都屬于 tableview 中的 “Preferences” 部分,羅列最喜歡的運動和顏色選項內容。即使早前已經提及過,但是我覺得還是有必要重新讓你回憶下,再次重申:當你點擊選擇某個選項后,相應的頂級單元格應該隨之折疊(隱藏那些選項),并將選中的值顯示到頂級單元格中。

我之所以選擇處理這種類型的單元格為先,原因在于我可以繼續在上部分的 tableview 代理方法中進行工作。方法中,我們將添加一個 else 分支處理 non-expandable 單元格的情況,接著檢查點擊單元格的標識符。如果標識符為“idCellValuePicker”,這就是我們感興趣的單元格。

func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
    let indexOfTappedRow = visibleRowsPerSection[indexPath.section][indexPath.row]
 
    if cellDescriptors[indexPath.section][indexOfTappedRow]["isExpandable"] as! Bool == true {
        ...
    }
    else {
        if cellDescriptors[indexPath.section][indexOfTappedRow]["cellIdentifier"] as! String == "idCellValuePicker" {
 
        }
    }
 
    getIndicesOfVisibleRows()
    tblExpandable.reloadSections(NSIndexSet(index: indexPath.section), withRowAnimation: UITableViewRowAnimation.Fade)
}

if 分支內,我們將執行四種不同的任務:

  1. 首先,我們需要找到頂級單元格的行索引值,即你點擊選中的單元格的“父母”。事實上,我們采用自下而上(即從點擊選中的單元格開始向上遍歷)的方式對單元格描述數組執行一次搜索,首個屬性isExpandable = true的單元格就是我們想要的家伙。
  2. 接著,將頂級單元格中的 textLabel 標簽值設置為選中單元格的值。
  3. 然后,設置頂級單元格的 isExpanded 等于 false ,即折疊狀態。
  4. 最后,標記頂級單元格下的所有子單元格為不可見狀態。

現在代碼如下:

func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
    let indexOfTappedRow = visibleRowsPerSection[indexPath.section][indexPath.row]
 
    if cellDescriptors[indexPath.section][indexOfTappedRow]["isExpandable"] as! Bool == true {
        ...
    }
    else {
        if cellDescriptors[indexPath.section][indexOfTappedRow]["cellIdentifier"] as! String == "idCellValuePicker" {
            var indexOfParentCell: Int!
            
            // 任務一
            for var i=indexOfTappedRow - 1; i>=0; --i {
                if cellDescriptors[indexPath.section][i]["isExpandable"] as! Bool == true {
                    indexOfParentCell = i
                    break
                }
            }
            // 任務二
            cellDescriptors[indexPath.section][indexOfParentCell].setValue((tblExpandable.cellForRowAtIndexPath(indexPath) as! CustomCell).textLabel?.text, forKey: "primaryTitle")
            
            // 任務三
            cellDescriptors[indexPath.section][indexOfParentCell].setValue(false, forKey: "isExpanded")
    
            // 任務四
            for i in (indexOfParentCell + 1)...(indexOfParentCell + (cellDescriptors[indexPath.section][indexOfParentCell]["additionalRows"] as! Int)) {
                cellDescriptors[indexPath.section][i].setValue(false, forKey: "isVisible")
            }
        }
    }
 
    getIndicesOfVisibleRows()
    tblExpandable.reloadSections(NSIndexSet(index: indexPath.section), withRowAnimation: UITableViewRowAnimation.Fade)
}

我們再次修改了單元格中的“isVisible”屬性,所有可見行的數量也隨之改變。顯然調用上述代碼中的最后兩個函數是非常有必要的。

現在如果你運行應用,實現效果如下:

Responding to Other User Actions(求翻譯)

打開 CustomCell.swift 文件,找到 CustomCellDelegate 的協議聲明,其中定義了一系列需要的協議方法。通過在 ViewController 類中實現它們,我們將設法使應用程序響應所有缺省的用戶操作。

讓我們再次回到 ViewController.swift 文件,首先我們需要遵循該協議。定位到類的頭部聲明行,添加如下內容:

class ViewController: UIViewController, UITableViewDelegate, UITableViewDataSource, CustomCellDelegate

接著,在 tableView:cellForRowAtIndexPath: 函數中,我們必須將每個自定義單元格的代理設置為 ViewController 類(即 self)。定位到那里,就在return cell 的上方添加一行代碼:

func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
    ...
 
    // 設置代理
    cell.delegate = self
 
    return cell
}

干得不錯,現在我們開始實現代理方法。首先,我們將 date picker 控件中選中的日期顯示到相應頂級單元格中:

func dateWasSelected(selectedDateString: String) {
    let dateCellSection = 0
    let dateCellRow = 3
 
    cellDescriptors[dateCellSection][dateCellRow].setValue(selectedDateString, forKey: "primaryTitle")
    tblExpandable.reloadData()
}

一旦我們指定了正確的 section 和 row, 直接賦值字符串類型的日期值。注意該字符串是代委托方法中的一個參數。

接著,我們處理有關 switch 控件的事務。當 switch 控件值改變時,我們需要做兩件事:首先,將頂級單元格內容設置為結果值(“Single” 或 “Married”),接著更新 cellDescriptor 數組中的 switch 控件值,這樣每次 tableview 刷新時它都擁有正確的狀態。下面的代碼片段中,你會注意我們首次根據 switch 控件狀態來確定適當的值,接著將它們賦值給相應屬性:

func maritalStatusSwitchChangedState(isOn: Bool) {
    let maritalSwitchCellSection = 0
    let maritalSwitchCellRow = 6
 
    let valueToStore = (isOn) ? "true" : "false"
    let valueToDisplay = (isOn) ? "Married" : "Single"
 
    cellDescriptors[maritalSwitchCellSection][maritalSwitchCellRow].setValue(valueToStore, forKey: "value")
    cellDescriptors[maritalSwitchCellSection][maritalSwitchCellRow - 1].setValue(valueToDisplay, forKey: "primaryTitle")
    tblExpandable.reloadData()
}

接下來是包含了 textField 控件的單元格。此處一旦有 first name 或 last name 輸入,我們會動態組合成 full name。出于需要,我們將獲取到包含 textField 控件單元格的行索引值,這樣就能為 full name 設置給定值了(first name + last name)。最后我們更新頂級單元格內的顯示本文內容(full name)和刷新 tableview 。

func textfieldTextWasChanged(newText: String, parentCell: CustomCell) {
    let parentCellIndexPath = tblExpandable.indexPathForCell(parentCell)
 
    let currentFullname = cellDescriptors[0][0]["primaryTitle"] as! String
    let fullnameParts = currentFullname.componentsSeparatedByString(" ")
 
    var newFullname = ""
 
    if parentCellIndexPath?.row == 1 {
        if fullnameParts.count == 2 {
            newFullname = "\(newText) \(fullnameParts[1])"
        }
        else {
            newFullname = newText
        }
    }
    else {
        newFullname = "\(fullnameParts[0]) \(newText)"
    }
 
    cellDescriptors[0][0].setValue(newFullname, forKey: "primaryTitle")
    tblExpandable.reloadData()
}

最后在 “Work Experience” 部分中,我們處理那些內含 slider 控件的單元格。當用戶改變 slider 控件值的同時,我們需要做兩件事:
首先將頂級單元格中的文本標簽內容設置為新的 slider 控件值,接著將 slider 控件值保存到對應的單元格描述中,這樣即使刷新 tableview 后,它始終是最新數據。

func sliderDidChangeValue(newSliderValue: String) {
    cellDescriptors[2][0].setValue(newSliderValue, forKey: "primaryTitle")
    cellDescriptors[2][1].setValue(newSliderValue, forKey: "value")
 
    tblExpandable.reloadSections(NSIndexSet(index: 2), withRowAnimation: UITableViewRowAnimation.None)
}

最后的缺省代碼添加完畢,運行應用。

總結

正如一開始我所說的,創建一個可展開的 tableview 有時真的很有用,它可以將你從麻煩中拯救出來,無須再為應用各部分創建一個新的視圖控制器。本教程的前部分中,我向你介紹了一種創建可展開的 tableview 的方法,其主要特點是所有單元格的描述都存放在屬性列表文件(plist 文件)中。教程中,我向你展示了如何在顯示、展開和選中單元格情況下,編寫代碼處理單元格描述列表;另外,我還向你提供了一種方式來直接更新用戶輸入的數據。盡管演示應用中的偽造表格在實際應用開發中所有作為,但想要作為一個完整的組件之前,你還需要實現一些功能(比如,把表單描述列表保存到文件中)。不過,這已經超出了我們的教學范疇;一開始我們只想要實現一個可展開的 tableview ,隨心所欲地顯示或隱藏單元格,最終也得以實現。我確信你會找到本教程
的價值。通過已有的代碼,你肯定能在此基礎上改進,并根據需求使用它。現在留點時間給你;玩得開心,切記學無止境!

參考: 完整項目代碼下載地址.

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,002評論 6 542
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,400評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,136評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,714評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,452評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,818評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,812評論 3 446
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,997評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,552評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,292評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,510評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,035評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,721評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,121評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,429評論 1 294
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,235評論 3 398
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,480評論 2 379

推薦閱讀更多精彩內容