視圖控制器 (View Controller) 這個元件提供基本構建塊,作為我們在 iOS 開發中構建 App 的基礎。在 Apple MVC 世界中,它是視圖 (View) 和模型 (Model) 之間的橋樑,充當兩者之間的協調者。控制器是一個觀察者,針對模型的更改作回應,然後更新視圖;並從使用 Target Action 的視圖接受用戶操作,然後更新模型。
(圖片來源:Apple Inc.)
身為 iOS 開發人員,即使我們使用 MVVM、MVP 或 VIPER 等架構,我們還是經常面對 Massive View Controller 的問題。有時候,視圖控制器在單一螢幕上職責過多,違反了單一責任原則 (Single Responsibility Principle, SRP),導致模組之間產生緊密耦合,難以重用及測試每個元件。
以下面的 App 截圖為範例,你可以看到一個螢幕中至少有三件事情要處理:
- 顯示電影列表
- 顯示可以選擇應用於電影列表的過濾器列表
- 清除所選過濾器的設定
如果我們使用單一視圖控制器構建這螢幕,視圖控制器肯定會變得非常臃腫,因為它在一個螢幕中要處理的職責太多了。
怎樣才能解決這個問題呢?其中一個解決方案是使用視圖控制器容器 (View Controller Containment) 和子視圖控制器 (Child View Controller)。讓我們來看看這個解決方案的好處:
- 將電影列表封裝到
MovieListViewController
中,它只負責顯示電影列表,並對Movie
模型的更改作出反應。如果我們想只顯示沒有過濾器的電影列表,我們也可以在另一個螢幕中重用這個MovieListViewController
- 將列表呈現 (listing) 和過濾選項 (selection of filters) 的邏輯封裝到
FilterListViewController
中,它只負責顯示和處理過濾選項。當用戶選擇和取消某個過濾選項時,我們可以使用 Delegate 與父視圖控制器進行溝通。 - 將主視圖控制器減小為一個
ContainerViewController
,它只負責將選定的過濾條件從過濾器列表應用到MovieListViewController
中的Movie
模型,同時設置佈局,並使用容器視圖 (Container View) 添加子視圖控制器。
你可以在 GitHub 上查看完整的專案原始碼。
使用 Storyboard 的視圖控制器結構
觀察上面的 Storyboard,以下是我們用來構建過濾器螢幕的視圖控制器:
ContainerViewController
:這個視圖控制器容器提供了兩個容器視圖,將子視圖控制器嵌入到水平的UIStackView
中。它提供了一個UIButton
來取消已選擇的過濾選項。它還嵌入到一個做為初始視圖控制器的UINavigationController
中。FilterListMovieController
:這個視圖控制器是UITableViewController
的子類,帶有 Grouped 樣式、和一個用來顯示過濾選項名稱的 Standard Cell。它還配有 Storyboard ID,因此可以通過編程方式從ContainerViewController
實例化。MovieListViewController
:這個視圖控制器是UITableViewController
的子類,帶有 Plain 樣式、和一個用來顯示Movie
屬性的 Subtitle Cell。它像FilterListViewController
一樣,也配有 Storyboard ID。
電影列表視圖控制器
電影列表視圖控制器負責顯示作為實例屬性公開的 Movie
模型列表,我們使用 Swift didSet
屬性觀察器來響應模型中的更改,然後重新加載 UITableView
。每個 Cell 使用預設 subtitle UITableViewCellStyle
的樣式,來顯示 Movie
的 Title(標題)、Duration(片長)、Rating(評價)和 Genre(類型)。
import UIKit
struct Movie {
let title: String
let genre: String
let duration: TimeInterval
let rating: Float
}
class MovieListViewController: UITableViewController {
var movies = [Movie]() {
didSet {
tableView.reloadData()
}
}
let formatter: DateComponentsFormatter = {
let formatter = DateComponentsFormatter()
formatter.allowedUnits = [.hour, .minute]
formatter.unitsStyle = .abbreviated
formatter.maximumUnitCount = 1
return formatter
}()
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return movies.count
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
let movie = movies[indexPath.row]
cell.textLabel?.text = movie.title
cell.detailTextLabel?.text = "\(formatter.string(from: movie.duration) ?? ""), \(movie.genre.capitalized), rating: \(movie.rating)"
return cell
}
}
過濾列表視圖控制器
過濾器列表在三個不同的 section 中顯示 MovieFilter
列舉 (enum):genre、rating、duration。MovieFilter
列舉遵從 Hashable
協定,因此可以使用每個列舉及其屬性的 hash 值,將列舉獨立地儲存於 Set
中。過濾選項的選擇則是儲存在包含 MovieFilter 的 Set 實例屬性下。
要與其他物件溝通,我們可以利用 FilterListControllerDelegate
使用委任模式 (Delegate Pattern)。實現委任的方法有三種:
- 選取過濾條件
- 取消已選取的過濾條件
- 清除所有已選的過濾條件
import UIKit
enum MovieFilter: Hashable {
case genre(code: String, name: String)
case duration(duration: TimeInterval, name: String)
case rating(value: Float, name: String)
var hashValue: Int {
switch self {
case .genre(let code, let name):
return "\(code)-\(name)".hashValue
case .rating(let value, let name):
return "\(value)-\(name)".hashValue
case .duration(let duration, let name):
return "\(duration)-\(name)".hashValue
}
}
}
protocol FilterListViewControllerDelegate: class {
func filterListViewController(_ controller: FilterListViewController, didSelect filter: MovieFilter)
func filterListViewController(_ controller: FilterListViewController, didDeselect filter: MovieFilter)
func filterListViewControllerDidClearFilters(controller: FilterListViewController)
}
class FilterListViewController: UITableViewController {
let filters = MovieFilter.defaultFilters
weak var delegate: FilterListViewControllerDelegate?
var selectedFilters: Set<:MovieFilter>: = []
override func viewDidLoad() {
super.viewDidLoad()
}
func clearFilter() {
selectedFilters.removeAll()
delegate?.filterListViewControllerDidClearFilters(controller: self)
tableView.reloadData()
}
override func numberOfSections(in tableView: UITableView) -> Int {
return filters.count
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return filters[section].filters.count
}
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
return filters[section].title
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
let filter = filters[indexPath.section].filters[indexPath.row]
if selectedFilters.contains(filter) {
selectedFilters.remove(filter)
delegate?.filterListViewController(self, didDeselect: filter)
} else {
selectedFilters.insert(filter)
delegate?.filterListViewController(self, didSelect: filter)
}
tableView.reloadRows(at: [indexPath], with: .automatic)
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
let filter = filters[indexPath.section].filters[indexPath.row]
switch filter {
case .genre(_, let name):
cell.textLabel?.text = name
case .rating(_, let name):
cell.textLabel?.text = name
case .duration(_, let name):
cell.textLabel?.text = name
}
if selectedFilters.contains(filter) {
cell.accessoryType = .checkmark
} else {
cell.accessoryType = .none
}
return cell
}
}
視圖控制器容器內的整合
在 ContainerViewController
裡面,我們有幾個實例屬性:
FilterListContainerView
&MovieListContainerView
:用於添加子視圖控制器的容器視圖。FilterListViewController
&MovieListViewController
:使用 Storyboard ID 實例化的影片列表和篩選器列表視圖控制器的引用。movie
:使用預設 hardcode 電影實例的Movie
陣列。
當調用 viewDidLoad
時,我們呼叫該方法來設置子視圖控制器。以下是它執行的任務:
- 使用 Storyboard ID 實例化
FilterListViewController
和MovieListViewController
; - 將它們分配給實例屬性;
- 將
MovieListViewController
分配給 movies 陣列; - 將
ContainerViewController
指定為FilterListViewController
的delegate
,以便它回應過濾器選擇; - 設置子視圖框架,並使用擴展 (extension) 幫助方法將它們添加為子視圖控制器。
對於 FilterListViewControllerDelegate
的實作,當選擇或取消某個過濾條件時,預設的電影數據就會根據相應的類型、評分、和片長來過濾。然後,過濾的結果將分配給 MovieListViewController
的 movies
屬性。當取消選擇所有過濾條件時,它就會分配預設的電影數據。
import UIKit
class ContainerViewController: UIViewController {
@IBOutlet weak var filterListContainerView: UIView!
@IBOutlet weak var movieListContainerView: UIView!
var filterListVC: FilterListViewController!
var movieListVC: MovieListViewController!
let movies = Movie.defaultMovies
override func viewDidLoad() {
super.viewDidLoad()
setupChildViewControllers()
}
private func setupChildViewControllers() {
let storyboard = UIStoryboard(name: "Main", bundle: nil)
let filterListVC = storyboard.instantiateViewController(withIdentifier: "FilterListViewController") as! FilterListViewController
addChild(childController: filterListVC, to: filterListContainerView)
self.filterListVC = filterListVC
self.filterListVC.delegate = self
let movieListVC = storyboard.instantiateViewController(withIdentifier: "MovieListViewController") as! MovieListViewController
movieListVC.movies = movies
addChild(childController: movieListVC, to: movieListContainerView)
self.movieListVC = movieListVC
}
@IBAction func clearFilterTapped(_ sender: Any) {
filterListVC.clearFilter()
}
private func filterMovies(moviesFilter: [MovieFilter]) {
movieListVC.movies = movies
.filter(with: moviesFilter.genreFilters)
.filter(with: moviesFilter.ratingFilters)
.filter(with: moviesFilter.durationFilters)
}
}
extension ContainerViewController: FilterListViewControllerDelegate {
func filterListViewController(_ controller: FilterListViewController, didSelect filter: MovieFilter) {
filterMovies(moviesFilter: Array(controller.selectedFilters))
}
func filterListViewController(_ controller: FilterListViewController, didDeselect filter: MovieFilter) {
filterMovies(moviesFilter: Array(controller.selectedFilters))
}
func filterListViewControllerDidClearFilters(controller: FilterListViewController) {
movieListVC.movies = Movie.defaultMovies
}
}
總結
通過這個範例專案,我們可以看到在 App 內使用視圖控制器容器和子視圖控制器的好處。我們可以將單個視圖控制器的職責,分給多個視圖控制器,每個視圖控制器只具有單一職責。我們還需要確保,子視圖控制器對其父級沒有任何依賴。為了讓子視圖控制器與父級進行通信,我們可以使用委任模式。
這個方法還有模塊鬆耦合的優點,提高每個元件的可重用性和可測試性。隨著我們的 App 變得更大、更複雜,這個方法確實有助於我們擴展它。讓我們一起繼續學習吧 📖!
FB : https://www.facebook.com/yishen.chen.54
Twitter : https://twitter.com/YeEeEsS