はじめに
今回は、ViewControllerで画面遷移をおこなうときに責務を分離して、変更に強くなるようなコードを紹介したいと思います。
以下のようなアプリを例として作ってみたいと思います。
GitHub
共通部分
struct User {
let name: String
let job: String
}
final class TaskListTableViewCell: UITableViewCell {
@IBOutlet private weak var nameLabel: UILabel!
@IBOutlet private weak var jobLabel: UILabel!
var onTapEvent: (() -> Void)?
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
onTapEvent?()
}
}
// MARK: - setup
extension TaskListTableViewCell {
func setup(user: User, onTapEvent: (() -> Void)?) {
self.onTapEvent = onTapEvent
nameLabel.text = user.name
jobLabel.text = user.job
}
}
細かな制約は今回の趣旨と関係がないので、GitHubをクローンして確認していただくか、適当に制約はつけておいてください!
変更前
final class TaskListViewController: UIViewController {
@IBOutlet weak var tableView: UITableView!
private var users = [User]()
override func viewDidLoad() {
super.viewDidLoad()
tableView.dataSource = self
tableView.register(UINib(nibName: "TaskListTableViewCell", bundle: nil),
forCellReuseIdentifier: "TaskListTableViewCell")
tableView.tableFooterView = UIView()
}
@IBAction private func addButtonDidTapped(_ sender: Any) {
let addTaskVC = UIStoryboard(name: "AddTask", bundle: nil).instantiateViewController(identifier: "AddTaskViewController") as! AddTaskViewController
addTaskVC.onTapEvent = { user in
guard let user = user else { return }
self.users.append(user)
self.tableView.reloadData()
}
addTaskVC.modalPresentationStyle = .fullScreen
present(addTaskVC, animated: true, completion: nil)
}
}
// MARK: - UITableViewDataSource
extension TaskListViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return users.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "TaskListTableViewCell") as! TaskListTableViewCell
let user = users[indexPath.row]
cell.setup(user: user) { [weak self] in
self?.presentUserInfoVC(user: user)
}
return cell
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return 100
}
private func presentUserInfoVC(user: User) {
let userInfoVC = UIStoryboard(name: "UserInfo", bundle: nil).instantiateViewController(identifier: "UserInfoViewController") as! UserInfoViewController
userInfoVC.user = user
userInfoVC.modalPresentationStyle = .fullScreen
present(userInfoVC, animated: true, completion: nil)
}
}
final class AddTaskViewController: UIViewController {
@IBOutlet private weak var nameTextField: UITextField!
@IBOutlet private weak var jobTextField: UITextField!
@IBOutlet private weak var backButton: UIButton!
var onTapEvent: ((User?) -> Void)?
@IBAction private func backButtonDidTapped(_ sender: Any) {
guard let nameText = nameTextField.text,
let jobText = jobTextField.text else { return }
let user = (nameText.isEmpty || jobText.isEmpty) ? nil : User(name: nameText, job: jobText)
onTapEvent?(user)
dismiss(animated: true, completion: nil)
}
}
final class UserInfoViewController: UIViewController {
@IBOutlet private weak var nameLabel: UILabel!
@IBOutlet private weak var jobLabel: UILabel!
var user: User?
override func viewDidLoad() {
super.viewDidLoad()
nameLabel.text = user?.name
jobLabel.text = user?.job
}
@IBAction private func backButtonDidTapped(_ sender: Any) {
dismiss(animated: true, completion: nil)
}
}
変更後
final class TaskListViewController: UIViewController {
@IBOutlet weak var tableView: UITableView!
private var users = [User]()
override func viewDidLoad() {
super.viewDidLoad()
tableView.dataSource = self
tableView.register(TaskListTableViewCell.nib,
forCellReuseIdentifier: TaskListTableViewCell.identifier)
tableView.tableFooterView = UIView()
}
@IBAction private func addButtonDidTapped(_ sender: Any) {
let addTaskVC = AddTaskViewController.instantiate { user in
guard let user = user else { return }
self.users.append(user)
self.tableView.reloadData()
}
addTaskVC.modalPresentationStyle = .fullScreen
present(addTaskVC, animated: true, completion: nil)
}
}
// MARK: - UITableViewDataSource
extension TaskListViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return users.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: TaskListTableViewCell.identifier) as! TaskListTableViewCell
let user = users[indexPath.row]
cell.setup(user: user) { [weak self] in
self?.presentUserInfoVC(user: user)
}
return cell
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return 100
}
private func presentUserInfoVC(user: User) {
let userInfoVC = UserInfoViewController.instantiate(user: user)
userInfoVC.modalPresentationStyle = .fullScreen
present(userInfoVC, animated: true, completion: nil)
}
}
final class AddTaskViewController: UIViewController {
@IBOutlet private weak var nameTextField: UITextField!
@IBOutlet private weak var jobTextField: UITextField!
@IBOutlet private weak var backButton: UIButton!
private var onTapEvent: ((User?) -> Void)?
@IBAction private func backButtonDidTapped(_ sender: Any) {
guard let nameText = nameTextField.text,
let jobText = jobTextField.text else { return }
let user = (nameText.isEmpty || jobText.isEmpty) ? nil : User(name: nameText, job: jobText)
onTapEvent?(user)
dismiss(animated: true, completion: nil)
}
static func instantiate(onTapEvent: @escaping (User?) -> Void) -> AddTaskViewController {
let addTaskVC = UIStoryboard.addTask.instantiateViewController(
identifier: AddTaskViewController.identifier
) as! AddTaskViewController
addTaskVC.onTapEvent = onTapEvent
return addTaskVC
}
}
private extension UIStoryboard {
static var addTask: UIStoryboard {
UIStoryboard(name: "AddTask", bundle: nil)
}
}
final class UserInfoViewController: UIViewController {
@IBOutlet private weak var nameLabel: UILabel!
@IBOutlet private weak var jobLabel: UILabel!
private var user: User?
override func viewDidLoad() {
super.viewDidLoad()
nameLabel.text = user?.name
jobLabel.text = user?.job
}
@IBAction private func backButtonDidTapped(_ sender: Any) {
dismiss(animated: true, completion: nil)
}
static func instantiate(user: User) -> UserInfoViewController {
let userInfoVC = UIStoryboard.userInfo.instantiateViewController(identifier: UserInfoViewController.identifier) as! UserInfoViewController
userInfoVC.user = user
return userInfoVC
}
}
private extension UIStoryboard {
static var userInfo: UIStoryboard {
UIStoryboard(name: "UserInfo", bundle: nil)
}
}
解説
1番のポイントは、遷移元で遷移先の変更に応じて変更をする必要がなくなったところです。
例えば、変更後のコードは、TaskListViewController(遷移元)からAddTaskViewController(遷移先)に遷移しますが、AddTaskViewControllerに何かTaskListViewControllerから渡したいものが増えたとしても、AddTaskViewControllerを変更するだけですみます。TaskListViewControllerは余分な情報を持ちすぎなくてすみます。TaskListViεwControllerは、AddTaskViewControllerに遷移さえできればいいので、AddTaskViewControllerに何を渡すのかに関しては関心がありません。どうでもいいことです。なので、その情報はAddTaskViewControllerに直接持たせました。さらに、こうすることで、AddTaskViewControllerのonTapEvent変数はprivateをつけることができます。
以下のコードに関しては、AddTaskViewControllerでしか使わないことが保証されていることと、AddTaskViewControllerでしか必要がない情報なので、private extensionとすることでこのファイル内でしか扱えないようにしました。そもそも、extension UIStoryboardを作るかどうかは、好みなので、そこはお任せします。
private extension UIStoryboard {
static var addTask: UIStoryboard {
UIStoryboard(name: "AddTask", bundle: nil)
}
}
遷移先がUserInfoViewControllerの場合も同様です。
さらに、ハードコーディングをしないことも重要ですが、今回の趣旨と関係ないので、説明は省きます。以下の記事とGitHubなどを参考に、UITableViewCellのidなどをハードコーディングしないようにしてみてください!
おわりに
この記事がいいなと思っていただけたら、LGTMとストックをお願いします!