大叔剛入行時,還是 iPhone 4s 的時代,那時後手機上的硬體資源有限,記憶體還很珍貴,有這麼多 APP 要一起共享,所以在寫 APP 的時候,記憶體的使用就需要特別的注意,不管是要 alloc ( 記憶體配置 ) 變數或是 free ( 記憶體釋放 ) 都要小心,一不留意 APP 就會跟大叔閃退 say goodbye。經典的UIKit 框架就誕生在這樣的背景中,在 UIKit 中主力是使用 class ( 類別 ) 的型別,它是一種 reference type ,而來到了 iPhone 16 ( iOS 18 ) 的時代,有了 AI 的剛需,iPhone 記憶體 ( RAM ) 來到了傳聞中的 8G,現在的 SwiftUI,就由 class 改變成 struct ( 結構 ),struct 是一種 value type 的型別。那蘋果為何從 class 轉性成 struct?從指標指來指去的 refernce type 轉成,每次使都要 clonecreatevalue type ?全是為了兩個字:安全。至於 struct 怎麼來個安全法,這又是另一篇故事了😎

01_class_struct

Classic Cell Configuration

實務上 APP 常使用到列表頁 (UITableView) 的 UI,如果要用 UIKit 來純寫 code 來實作的話,通常會先在 UIViewController 上 create UITableView,在這 table view 上註冊我們寫 code 客製化自訂的 cell ( 繼承 UITableViewCell ) 如

// register cells
tableView.register(NewsCell.self, forCellReuseIdentifier: NewsCell.identifier)

然後在 table view 的 data source 中,create cell ,並設定重複使用這 cell ,然後把資料 ( model ) 傳入 cell 後,回傳。

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    // classic cell for custom cell in programming
    let cell = tableView.dequeueReusableCell(withIdentifier: NewsCell.identifier, for: indexPath) as! NewsCell
    let a_news = news[indexPath.row]
    cell.set(news: a_news)
        
    return cell    
}

而我們這客製化的 cell,data type 為 class ,所以它可以繼承 UITableViewCell 這蘋果先幫我們寫好的 cell。

import UIKit

class NewsCell: UITableViewCell {

    static let identifier = "NewsCell"

    var newsImageView = UIImageView()
    var newsTitleLabel = UILabel()
    var newsSourceLabel = UILabel()
    
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        layoutViews()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    func set(news: News) {
        newsImageView.image = news.image
        newsTitleLabel.text = news.title
        newsSourceLabel.text = news.source
    }
	
	...
}

完整程式:ModernCellConfigurationExe/ClassicCellConfig/NewsCell.swift

由以上的範例,看到傳入 cell 的資料,直接設定在 cell 的 UI 元件 ( UIImageView & UILabel ) 上,這套經典的寫法大叔把它稱作「Classic Cell Configuration 」,十多年來這 Table View Cell 的經典設計款,蘋果大概是很難將它下架吧!但時代在變 寫 APP 的方法也在變,在 WWDC 2020 時,我們蘋果教主提出了一套「Modern Cell Configuration」的新設計,作為 Table View Cell 的新時代之作,想必蘋果圖的心思是想讓 UIKit 繼續存活個好幾年吧,但教主心思豈止於此,待大叔娓娓道來😎

Modern cell configuration - WWDC20 - Videos - Apple Developer

Modern Cell Configuration

STEP1

先 create 另一個新的 cell 如

import UIKit

class NewsCell_MCC: UITableViewCell {
    static let identifier = "NewsCell_MCC"
}

STEP2

下一步 create 一新的 struct,如取名為 NewsContentConfiguration,其中這 struct 需遵從 UIContentConfigurationprotocol ( 協定 ),Swift 規定 struct 無法繼承只能遵從協定 ( protocol ) 如

03_content_configuration

UIContentConfiguration 協定,必需要實作兩個 Instance Methods

import UIKit

struct NewsContentConfiguration: UIContentConfiguration {
    func makeContentView() -> any UIView & UIContentView {
    }
    
    // update different style based on cell's state
    func updated(for state: any UIConfigurationState) -> NewsContentConfiguration {
    }
}

makeConentView()

可以產生 table view cell 的畫面 ( content view ),用在 Content Configuration

updated(for:)

在這 method 中可以先預設好 cell 的各個狀態 ( state ) 要顯示的 UI 或資料 ,之後再依傳入狀態 (UIConfigurationState),回傳 Content Configuration

STEP3

再來產生一 ContentView,即NewsContentConfigurationmakeContentView() 需回傳的。如

04_content_view

NewsContentViewclass,除了繼承 了 UIView 外 ,還需要遵從 UIContentView 協定

05_uicontentview

UIContentView,需要宣告參數 var configuration: any UIContentConfiguration,目的是用來傳入 STEP2 中的 configuration

import UIKit

class NewsContentView: UIView, UIContentView {
    var configuration: any UIContentConfiguration
    
    init(configuration: any UIContentConfiguration) {
        self.configuration = configuration
        super.init(frame: .zero)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

STEP4

參考範例程式把 NewsContentConfigurationNewsContentView 剩下的部分補上

Explanation

NewsContentConfiguration

struct NewsContentConfiguration: UIContentConfiguration {
    // data
    let news: News
    
    func makeContentView() -> any UIView & UIContentView {
        return NewsContentView(configuration: self)
    }
    
    // update different style based on cell's state
    func updated(for state: any UIConfigurationState) -> NewsContentConfiguration {
        return self
    }
}

如上,先宣告要顯示的資料 ( News Model ) ,而 makeContentView 中則回傳 SETP3 中的 content view ,並同時也把自己 self ( NewsContentConfiguration ),傳入 content view 裡,至於 update(for:) 暫時先回傳自己 ( self )。

NewsContentView

Classic Cell Configuration 中,我們會直接在 table view cell 中設定 UI ( UIImageView & UILabel ) 的 layout 和其值,如

func set(news: News) {
    newsImageView.image = news.image
    newsTitleLabel.text = news.title
    newsSourceLabel.text = news.source
}

但在 Modern Cell Configuration 中,我們可以把原本 cell 中的設定,盡數搬到 content view 中,如

private func layoutViews() {
    addSubviews()
    configureSubViews()
    setConstraints()    
}

而設定 UI 資料,則需透過 content configuration 參數傳入,如

private func setNews() {
    guard let config = configuration as? NewsContentConfiguration else {
        return
    }

    newsImageView.image = config.news.image
    newsTitleLabel.text = config.news.title
    newsSourceLabel.text = config.news.source   
}

這傳入的 content configuration 是不是很像 MVVM 開發中的 View Model,這個 configurationstruct,對 iOS 來說是 struct 非常輕量,殺掉後再次產生,也不會有什麼負擔, struct 是蘋果的新歡,使用 configuration 可以把 UIdata 區分開來,同樣的 configuration 就可使用在不同的 content view 上。

NewsCell_MCC

經過 Modern Cell Configuration 的巧手協助, table view cell 可以簡化成:

import UIKit

class NewsCell_MCC: UITableViewCell {
    static let identifier = "NewsCell_MCC"

    func set(news: News) {
        contentConfiguration = NewsContentConfiguration(news: news)
    }
}

每次呼叫 cell 的 set(news:) 時,可以產生新的 NewsContentConfiguration,並把它設定為 cellcontentConfiguration ,它是 iOS 14 之後蘋果為 table view cell 新加入的屬性,它的資料型態就是 UIContentConfiguration

@available(iOS 14.0, tvOS 14.0, *)
extension UITableViewCell {
    
    @available(iOS 14.0, tvOS 14.0, *)
    @MainActor @preconcurrency public var contentConfiguration: (any UIContentConfiguration)?

    @available(iOS 14.0, tvOS 14.0, *)
    @MainActor @preconcurrency public func defaultContentConfiguration() -> UIListContentConfiguration
}

這時候可以執行程式了,跑起來結果應該和 classic cell configuration 相同。

Update Configuration State

原本用傳統的 classic cell configuration 的方式,要達成手指選到哪個 cell ,這 cell 就要能變換顏色,可能需要去宣告 table view delegate 的方法,去攔截接收手指 touch 到 cell 的 event 來變換顏色,整個感覺相當的麻煩與囉唆。

但如果用 modern cell configuration 來實作的話,可能會變得簡單一點。神奇的地方就發生在 configuration 中的 update(for:) 這 method,修改 content configuration

struct NewsContentConfiguration_US: UIContentConfiguration {
    // data
    let news: News
    // ui style
    var textColor: UIColor?
    
    func makeContentView() -> any UIView & UIContentView {
        return NewsContentView_US(configuration: self)
    }
    
    func updated(for state: any UIConfigurationState) -> NewsContentConfiguration_US {
        guard let state = state as? UICellConfigurationState else {
            return self
        }
        
        var updatedConfig = self
        // when cell is selected, text color would turn to white
        if state.isSelected {
            updatedConfig.textColor = .systemRed
        }else {
            updatedConfig.textColor = .black
        }
        
        return updatedConfig
    }
}

updated(for:) 中依傳入的 UIConfigurationState 來設定 cell 標題文字的顏色。先宣告 var textColor: UIColor? 成員變數,當 cell 被選 ( isSelected ) 到時 textColor 為紅色,而沒被選到則為黑色,而回傳值仍然是 return 自己 ( self ),只不過這個自己會根據 cell 不同的狀態 ( state ) 來改變 textColor

而在 table view cell,則新增這幾行

private var a_news = News(image: nil, title: nil, source: nil)

// when cell is selected, this update would be called
override func updateConfiguration(using state: UICellConfigurationState) {
	super.updateConfiguration(using: state)  
    // generate configuration for cell content based on cell's state
    let contentConfig = NewsContentConfiguration_US(news: a_news).updated(for: state)
    contentConfiguration = contentConfig        
}

func set(news: News) {
     contentConfiguration = NewsContentConfiguration_US(news: news)
     a_news = news
}

新增 private var a_news 來儲存 set(news:) 時傳入的 news

另外新增覆寫 ( override ) **updateConfiguration(using:)** 這 function,當每次 tableview cell 狀態 ( UICellConfigurationState ) 有改變時,系統會自動呼叫此 function,因此可以在 update 裡改變我們設計的 content configuration( 例如 NewsContentConfiguration_US ) ,並把 cell 狀態傳入。當回傳時就可以直接再次設定到 cell 的 contentConfiguration,因為 configurationstruct,所以 iOS 不怕我們一直產生一直設定。

最後 Content View 需修改如下,用以改變文字顏色

var configuration: any UIContentConfiguration {
    didSet {
        setStyle()
    }    
}

private func setStyle() {
	guard let config = configuration as? NewsContentConfiguration_US else {
		return
    }

    newsTitleLabel.textColor = config.textColor
    newsSourceLabel.textColor = config.textColor    
}

在原本的 configuration 裡新增 didSet 的呼叫,當傳入的 configuration 一有變動之後,就會去呼叫 didSet 裡的 setStyle ,用來設定 UILabeltextColor

行文到這,不知道有沒有人有疑問?用傳統寫 code 的方式來產生 table view cell 就已經夠囉唆了,為何在 iOS 14 蘋果要引進這看似囉唆複雜的 Modern Cell Configuration ,既要新增 content configuration 又要新增 content view?答案揭曉:原來這一切都是為了 SwiftUI 在鋪路啊!table view cell 有了 content configuration 的設計,就可以透過 UIHostingConfiguration(content:) 的引薦,直接引入 SwiftUI 所撰寫的 view,蘋果教主的這一點小心思,豈不妙哉😎

參考完整程式專案 https://github.com/theLittleApps/ModernCellConfigurationExample

References

=> Modern Cell Configuration in iOS 14 (Swift 5, Xcode 12, 2020) - iOS Development
=> 從 iOS 15 開始,使用內建 cell 樣式建議搭配 UIListContentConfiguration
=> iOS 14 — Modern Cell Configuration for Beginners (Programmatically)
=> The Developer’s Guide to Cell Content Configuration in iOS 14
=> how to use iOS 14 cell content configurations in general?
=> Rendering SwiftUI views within UITableView or UICollectionView cells on iOS 16
=> UITableViewCell in iOS 14
=> How to implement a text field cell with custom content configuration in iOS 14?
=> UICollectionView List with Custom Cell and Custom Configuration

贊助我們

Buy Me A Coffee