[譯] iOS:如何構建具備多種 Cell 類型的表視圖

第1部分:怎樣才能不迷失在大量代碼中html

在具備靜態 Cell 的表視圖中,其 Cell 的數量和順序是恆定的。要實現這樣的表視圖很是簡單,與實現常規 UIView 沒有太大的區別。前端

只包含一種內容類型的動態 Cell 的表視圖:Cell 的數量和順序是動態變化的,但全部 Cell 都有相同類型的內容。在這裏你可使用可複用 Cell 。這也是最多見的表視圖樣式。android

包含具備不一樣內容類型的動態 Cell 的表視圖:數量,順序和 Cel l類型是動態的。實現這種表視圖是最有趣和最具挑戰性的。ios

想象一下這個應用程序,你必須構建這樣的頁面:git

全部數據都來自後端,咱們沒法控制下一個請求將接收哪些數據:可能沒有「about」的信息,或者「gallery」部分多是空的。在這種狀況下,咱們根本不須要展現這些 Cell。最後,咱們必須知道用戶點擊的 Cell 類型並作出相應的反應。github

首先,讓咱們來先肯定問題。express

我常常在不一樣項目中看到這樣的方法:在 UITableView 中根據 index 配置 Cell。編程

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {

   if indexPath.row == 0 {
        //configure cell type 1
   } else if indexPath.row == 1 {
        //configure cell type 2
   }
   ....
}
複製代碼

一樣在代理方法 didSelectRowAt 中幾乎使用相同的代碼:json

override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {

if indexPath.row == 0 {
        //configure action when tap cell 1
   } else if indexPath.row == 1 {
        //configure action when tap cell 1
   }
   ....
}
複製代碼

直到你想要從新排序 Cell 或在表視圖中刪除或添加新的 Cell 的那一刻,代碼都將如所預期的工做。若是你更改了一個 index,那麼整個表視圖的結構都將破壞,你須要手動更新 cellForRowAtdidSelectRowAt 方法中全部的 index。swift

換句話說,它沒法重用,可讀性差,也不遵循任何編程模式,由於它混合了視圖和 Model。

有什麼更好的方法嗎?

在這個項目中,咱們將使用 MVVM 模式。MVVM 表明「Model-View-ViewModel」,當你在模型和視圖之間須要額外的視圖時,這種模式很是有用。你能夠在此處閱讀有關全部主要 iOS 設計模式 的更多信息。

在本系列教程的第一部分中,咱們將使用 JSON 做爲數據源構建動態表視圖。咱們將討論如下主題和概念:協議,協議拓展,屬性計算,聲明轉換 以及更多。

在下一個教程中,咱們將把它提升一個難度:經過幾行代碼來實現 section 的摺疊。。


第1部分: Model

首先,建立一個新項目,將 TableView 添加到默認的 ViewController 中,ViewController 綁定該 tableView,並將ViewController 嵌入到 NavigationController 中,並確保項目能按預期編譯和運行。這是基本步驟,此處不予介紹。若是你在這部分遇到麻煩,那對你來講深刻研究這個話題可能太早了。

你的 ViewController 類應該像這樣子:

class ViewController: UIViewController {
   @IBOutlet weak var tableView: UITableView?
 
   override func viewDidLoad() {
      super.viewDidLoad()
   }
}
複製代碼

我建立了一個簡單的 JSON 數據,來模仿服務器響應。你能夠在個人 Dropbox 中下載它。將此文件保存在項目文件夾中,並確保該文件的項目名稱與文件檢查器中的目標名稱相同:

你還須要一些圖片,你能夠在 這裏 找到。下載存檔,解壓縮,而後將圖片添加到資源文件夾。不要對任何圖片重命名。

咱們須要建立一個 Model,它將保存咱們從 JSON 讀取的全部數據。

class Profile {
   var fullName: String?
   var pictureUrl: String?
   var email: String?
   var about: String?
   var friends = [Friend]()
   var profileAttributes = [Attribute]()
}

class Friend {
   var name: String?
   var pictureUrl: String?
}

class Attribute {
   var key: String?
   var value: String?
}
複製代碼

咱們將給 JSON 對象添加初始化方法,那樣你就能夠輕鬆地將 JSON 映射到 Model。首先,咱們須要從 .json 文件中提取內容的方法,並將其轉成 Data 對象:

public func dataFromFile(_ filename: String) -> Data? {
   @objc class TestClass: NSObject { }
   let bundle = Bundle(for: TestClass.self)
   if let path = bundle.path(forResource: filename, ofType: "json") {
      return (try? Data(contentsOf: URL(fileURLWithPath: path)))
   }
   return nil
}
複製代碼

使用 Data 對象,咱們能夠初始化 Profile 類。原生或第三方庫中有許多不一樣的方能夠在 Swift 中解析JSON,你可使用你喜歡的那個。我堅持使用標準的 Swift JSONSerialization 庫來保持項目的精簡,不使用任何第三方庫:

class Profile {
   var fullName: String?
   var pictureUrl: String?
   var email: String?
   var about: String?
   var friends = [Friend]()
   var profileAttributes = [Attribute]()
   
   init?(data: Data) {
      do {
         if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], let body = json[「data」] as? [String: Any] {
            self.fullName = body[「fullName」] as? String
            self.pictureUrl = body[「pictureUrl」] as? String
            self.about = body[「about」] as? String
            self.email = body[「email」] as? String
            
            if let friends = body[「friends」] as? [[String: Any]] {
               self.friends = friends.map { Friend(json: $0) }
            }
            
            if let profileAttributes = body[「profileAttributes」] as? [[String: Any]] {
               self.profileAttributes = profileAttributes.map { Attribute(json: $0) }
            }
         }
      } catch {
         print(「Error deserializing JSON: \(error)」)
         return nil
      }
   }
}

class Friend {
   var name: String?
   var pictureUrl: String?
   
   init(json: [String: Any]) {
      self.name = json[「name」] as? String
      self.pictureUrl = json[「pictureUrl」] as? String
   }
}

class Attribute {
   var key: String?
   var value: String?
  
   init(json: [String: Any]) {
      self.key = json[「key」] as? String
      self.value = json[「value」] as? String
   }
}
複製代碼

第2部分:View Model

咱們的 Model 已準備就緒,因此咱們須要建立 ViewModel。它將負責向咱們的 TableView 提供數據。

咱們將建立 5 個不一樣的 table sections:

  • Full name and Profile Picture
  • About
  • Email
  • Attributes
  • Friends

前三個 section 各只有一個 Cell,最後兩個 section 能夠有多個 Cell,具體取決於咱們的 JSON 文件的內容。

由於咱們的數據是動態的,因此 Cell 的數量不是固定的,而且咱們對每種類型的數據使用不一樣的 tableViewCell,所以咱們須要使用正確的 ViewModel 結構。首先,咱們必須區分數據類型,以便咱們可使用適當的 Cell。當你須要在 Swift 中使用多種類型而且能夠輕鬆的切換時,最好的方法是使用枚舉。那麼讓咱們開始使用 ViewModelItemType 構建 ViewModel

enum ProfileViewModelItemType {
   case nameAndPicture
   case about
   case email
   case friend
   case attribute
}
複製代碼

每一個 enum case 表示 TableViewCell 須要的不一樣的數據類型。可是,我因爲們但願在同一個表視圖中使用數據,因此須要有一個單獨的 dataModelItem,它將決定全部屬性。咱們能夠經過使用協議來實現這一點,該協議將爲咱們的 item 提供屬性計算:

protocol ProfileViewModelItem {  

}
複製代碼

首先,咱們須要知道的是 item 的類型。所以咱們爲協議建立一個類型屬性。當你建立協議屬性時,你須要爲該屬性設置 name, type,並指定該屬性是 gettable 仍是 settablegettable。你能夠在 此處 得到有關協議屬性的更多信息和示例。在咱們的例子中,類型將是 ProfileViewModelItemType,咱們僅須要只讀該屬性:

protocol ProfileViewModelItem {
   var type: ProfileViewModelItemType { get }
}
複製代碼

咱們須要的下一個屬性是 rowCount。它將告訴咱們每一個 section 有多少行。爲此屬性指定類型和只讀類型:

protocol ProfileViewModelItem {
   var type: ProfileViewModelItemType { get }
   var rowCount: Int { get }
}
複製代碼

咱們最好在協議中添加一個 sectionTitle 屬性。基本上,sectionTitle 也屬於 TableView 的相關數據。如你所知,在使用 MVVM 結構時,除了在 viewModel 中,咱們不但願在其餘任何地方建立任何類型的數據,:

protocol ProfileViewModelItem {
   var type: ProfileViewModelItemType { get }
   var rowCount: Int { get }
   var sectionTitle: String  { get }
}
複製代碼

如今,咱們已經準備好爲每種數據類型建立 ViewModelItem。每一個 item 都須要遵照協議。但在咱們開始以前,讓咱們再向簡潔有序的項目邁出一步:爲咱們的協議提供一些默認值。在 swift 中,咱們可使用協議擴展爲協議提供默認值:

extension ProfileViewModelItem {
   var rowCount: Int {
      return 1
   }
}
複製代碼

如今,若是 rowCount 爲 1,咱們就沒必要爲 item 的 rowCount 賦值了,它將爲你節省一些冗餘的代碼。

協議擴展還容許您在不使用 @objc 協議的狀況下生成可選的協議方法。只需建立一個協議擴展並在這個擴展中實現默認方法。

先爲 nameAndPicture Cell 建立一個 ViewModeItem

class ProfileViewModelNameItem: ProfileViewModelItem {
   var type: ProfileViewModelItemType {
      return .nameAndPicture
   }
   
   var sectionTitle: String {
      return 「Main Info」
   }
}
複製代碼

正如我以前所說,在這種狀況下,咱們不須要爲 rowCount 賦值,由於,咱們只須要默認值 1。

如今咱們添加其餘屬性,這些屬性對於這個 item 來講是惟一的:pictureUrluserName。二者都是沒有初始值的存儲屬性,所以咱們還須要爲這個類提供 init 方法:

class ProfileViewModelNameAndPictureItem: ProfileViewModelItem {
   var type: ProfileViewModelItemType {
      return .nameAndPicture
   }
   
   var sectionTitle: String {
      return 「Main Info」
   }
   
   var pictureUrl: String
   var userName: String
   
   init(pictureUrl: String, userName: String) {
      self.pictureUrl = pictureUrl
      self.userName = userName
   }
}
複製代碼

而後咱們能夠建立剩餘的4個 Model:

class ProfileViewModelAboutItem: ProfileViewModelItem {
   var type: ProfileViewModelItemType {
      return .about
   }
   
   var sectionTitle: String {
      return 「About」
   }
   
   var about: String
  
   init(about: String) {
      self.about = about
   }
}

class ProfileViewModelEmailItem: ProfileViewModelItem {
   var type: ProfileViewModelItemType {
      return .email
   }
   
   var sectionTitle: String {
      return 「Email」
   }
   
   var email: String
   
   init(email: String) {
      self.email = email
   }
}

class ProfileViewModelAttributeItem: ProfileViewModelItem {
   var type: ProfileViewModelItemType {
      return .attribute
   }
   
   var sectionTitle: String {
      return 「Attributes」
   }
 
   var rowCount: Int {
      return attributes.count
   }
   
   var attributes: [Attribute]
   
   init(attributes: [Attribute]) {
      self.attributes = attributes
   }
}

class ProfileViewModeFriendsItem: ProfileViewModelItem {
   var type: ProfileViewModelItemType {
      return .friend
   }
   
   var sectionTitle: String {
      return 「Friends」
   }
   
   var rowCount: Int {
      return friends.count
   }
   
   var friends: [Friend]
   
   init(friends: [Friend]) {
      self.friends = friends
   }
}
複製代碼

對於 ProfileViewModeAttributeItemProfileViewModeFriendsItem,咱們可能會有多個 Cell,因此 RowCount 將是相應的 Attributes 數量和 Friends 數量。

這就是數據項所需的所有內容。最後一步是建立 ViewModel 類。這個類能夠被任何 ViewController 使用,這也是MVVM結構背後的關鍵思想之一:你的 ViewModelView 一無所知,但它提供了 View 可能須要的全部數據。

_ViewModel_擁有的惟一屬性是 item 數組,它對應着 UITableView 包含的 section 數組:

class ProfileViewModel: NSObject {
   var items = [ProfileViewModelItem]()
}
複製代碼

要初始化 ViewModel,咱們將使用 Profile Model。首先,咱們嘗試將 .json 文件解析爲 Data:

class ProfileViewModel: NSObject {
   var items = [ProfileViewModelItem]()
   
   override init(profile: Profile) {
      super.init()
      guard let data = dataFromFile("ServerData"), let profile = Profile(data: data) else {
         return
      }
      
      // initialization code will go here
   }
}
複製代碼

下面是最有趣的部分:基於 Model,咱們將配置須要顯示的 ViewModel

class ProfileViewModel: NSObject {
   var items = [ProfileViewModelItem]()
   
   override init() {
      super.init()
      guard let data = dataFromFile("ServerData"), let profile = Profile(data: data) else {
         return
      }
 
      if let name = profile.fullName, let pictureUrl = profile.pictureUrl {
         let nameAndPictureItem = ProfileViewModelNamePictureItem(name: name, pictureUrl: pictureUrl)
         items.append(nameAndPictureItem)
      }
      
      if let about = profile.about {
         let aboutItem = ProfileViewModelAboutItem(about: about)
         items.append(aboutItem)
      }
      
      if let email = profile.email {
         let dobItem = ProfileViewModelEmailItem(email: email)
         items.append(dobItem)
      }
      
      let attributes = profile.profileAttributes
      // we only need attributes item if attributes not empty
      if !attributes.isEmpty {
         let attributesItem = ProfileViewModeAttributeItem(attributes: attributes)
         items.append(attributesItem)
      }
      
      let friends = profile.friends
      // we only need friends item if friends not empty
      if !profile.friends.isEmpty {
         let friendsItem = ProfileViewModeFriendsItem(friends: friends)
         items.append(friendsItem)
      }
   }
}
複製代碼

如今,若是要從新排序、添加或刪除 item,只需修改此 ViewModel 的 item 數組便可。很清楚,是吧?

接下來,咱們將 UITableViewDataSource 添加到 ModelView:

extension ViewModel: UITableViewDataSource {
   func numberOfSections(in tableView: UITableView) -> Int {
      return items.count
   }
   
   func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
      return items[section].rowCount
   }
   
   func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
   
   // we will configure the cells here
   
   }
}
複製代碼

第3部分:View

讓咱們回到 ViewController 中,開始 TableView 的準備。

首先,咱們建立存儲屬性 ProfileViewModel 並初始化它。在實際項目中,你必須先請求數據,將數據提供給 ViewModel,而後在數據更新時從新加載 TableView在這裏查看在 iOS 應用程序中傳遞數據的方法)。

接下來,讓咱們來配置 tableViewDataSource:

override func viewDidLoad() {  
   super.viewDidLoad()  
     
   tableView?.dataSource = viewModel  
}
複製代碼

如今咱們能夠開始構建 UI 了。咱們須要建立五種不一樣類型的 Cell,每種 Cell 對應一種 ViewModelItems。如何建立 Cell 並非本教程中所須要介紹的內容,你能夠建立本身的 Cell 類、樣式和佈局。做爲參考,我將向你展現一些簡單示例:

NameAndPictureCell 和 FriendCell 示例

EmailCell 和 AboutCell 示例

AttributeCell 示例

若是你對建立 Cell 須要一些幫助,或者想要一些提示,能夠查看我以前關於 tableViewCells 的某個 教程

每一個 Cell 都應該具備 ProfileViewModelItem 類型的 item 屬性,咱們將使用它來構建 Cell UI:

// this assumes you already have all the cell subviews: labels, imagesViews, etc

class NameAndPictureCell: UITableViewCell {  
    var item: ProfileViewModelItem? {  
      didSet {  
         // cast the ProfileViewModelItem to appropriate item type  
         guard let item = item as? ProfileViewModelNamePictureItem  else {  
            return  
         }

         nameLabel?.text = item.name  
         pictureImageView?.image = UIImage(named: item.pictureUrl)  
      }  
   }  
}

class AboutCell: UITableViewCell {  
   var item: ProfileViewModelItem? {  
      didSet {  
         guard  let item = item as? ProfileViewModelAboutItem else {  
            return  
         }

         aboutLabel?.text = item.about  
      }  
   }  
}

class EmailCell: UITableViewCell {  
    var item: ProfileViewModelItem? {  
      didSet {  
         guard let item = item as? ProfileViewModelEmailItem else {  
            return  
         }

         emailLabel?.text = item.email  
      }  
   }  
}

class FriendCell: UITableViewCell {  
    var item: Friend? {  
      didSet {  
         guard let item = item else {  
            return  
         }

         if let pictureUrl = item.pictureUrl {  
            pictureImageView?.image = UIImage(named: pictureUrl)  
         }  
         nameLabel?.text = item.name  
      }  
   }  
}

var item: Attribute?  {  
   didSet {  
      titleLabel?.text = item?.key  
      valueLabel?.text = item?.value  
   }  
}
複製代碼

大家可能會提一個合理的問題:爲何咱們不爲 ProfileViewModelAboutItemProfileViewModelEmailItem 建立同一個的 Cell,他們都只有一個 label?答案是能夠這樣子作,咱們可使用一個的 Cell。但本教程的目的是向你展現如何使用不一樣類型的 Cell。

若是你想將它們用做 reusableCells,不要忘記註冊 Cell:UITableView 提供註冊 Cell class 和 nib 文件的方法,這取決於你建立 Cell 的方式。

如今是時候在 TableView 中使用 Cell 了。一樣,ViewModel 將以一種很是簡單的方式處理它:

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
   let item = items[indexPath.section]
   switch item.type {
   case .nameAndPicture:
      if let cell = tableView.dequeueReusableCell(withIdentifier: NamePictureCell.identifier, for: indexPath) as? NamePictureCell {
         cell.item = item
         return cell
      }
   case .about:
      if let cell = tableView.dequeueReusableCell(withIdentifier: AboutCell.identifier, for: indexPath) as? AboutCell {
         cell.item = item
         return cell
      }
   case .email:
      if let cell = tableView.dequeueReusableCell(withIdentifier: EmailCell.identifier, for: indexPath) as? EmailCell {
         cell.item = item
         return cell
      }
   case .friend:
      if let cell = tableView.dequeueReusableCell(withIdentifier: FriendCell.identifier, for: indexPath) as? FriendCell {
         cell.item = friends[indexPath.row]
         return cell
      }
   case .attribute:
      if let cell = tableView.dequeueReusableCell(withIdentifier: AttributeCell.identifier, for: indexPath) as? AttributeCell {
         cell.item = attributes[indexPath.row]
         return cell
      }
   }
   
   // return the default cell if none of above succeed
   return UITableViewCell()
}

你可使用相同的結構來構建 didSelectRowAt 代理方法:

override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
      switch items[indexPath.section].type {
          // do appropriate action for each type
      }
}
複製代碼

最後,配置 headerView

override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
   return items[section].sectionTitle
}
複製代碼

構建運行你的項目並享受動態表視圖!

結果圖

要測試該方法的靈活性,你能夠修改 JSON 文件:添加或刪除一些 friends 數據,或徹底刪除一些數據(只是不要破壞 JSON 結構,否則,你就沒法看到任何數據)。當你從新構建項目時,tableView 將以其應有的方式查找和工做,而無需任何代碼修改。 若是要更改 Model 自己,你只需修改 ViewModelViewController:添加新屬性,或重構其整個結構。固然那就要另當別論了。

在這裏,你能夠查看完整的項目:

Stan-Ost/TableViewMVVM

謝謝你的閱讀!若是你有任何問題或建議 - 請隨意提問!

在下一篇文章中,咱們將升級現有項目,爲這些 section 添加一個良好的摺疊/展開效果。


更新:在 此處 查看如何在不使用 ReloadData 方法的狀況下動態更新此 tableView


我同時也爲美國運通工程博客寫做。在 AmericanExpress.io 查看個人其餘做品和我那些才華橫溢的同事的做品。

若是發現譯文存在錯誤或其餘須要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可得到相應獎勵積分。文章開頭的 本文永久連接 即爲本文在 GitHub 上的 MarkDown 連接。


掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索