🙄

【翻訳】How To Fix Your Fat ViewController

2023/07/26に公開

iOS の開発ではよくあることですが、 UIViewController は Views 、 outlet 、 action 、 layout 、ビジネスロジックなど、あらゆる要素で埋め尽くされてしまうため、非常にゴチャゴチャして管理しづらくなります。

実際、 UIViewController は非常にシンプルであるべきです。 UIViewController は View を管理し、アクションの発生を監視するだけで良いのです。このチュートリアルでは、私がいつもプロジェクトに適用しているベストプラクティスの1つを共有したいと思います。

解決方法

画面を複数の UIView コンポーネント に分割し、それぞれの UIView コンポーネント が全てのレイアウトとスタイルを管理します。また、アクションや変更を ViewController に通知する責任も負います。その上、 UIView コンポーネント は最終的にプロジェクト全体で再利用可能になります。すごい!

このシンプルなプロフィールページ(上の画像)を開発するとしましょう。そこで、私たちの頭の中では、ViewController にこれらのものを全て配置することになるでしょう。単純に storyboard を開き、アウトレット(ラベル、画像)といくつかの制約を追加し始めることができます。その通りです。残念ながら、これは私たちがいつもやっている一般的なやり方です。

でも、これを読めば、もうそんな間違いを繰り返すことはないでしょう。

問題は、UI にさらに変更を加えたり、機能を追加したりするときに起こります。だから、 ViewController が太りやすくなります。

分割する

さて、 UIViewController は常にスリムなままにしておいて、別々の UIView に分割し始めたい。以下はその手順です。

  1. まず、 ViewController に UIScrollView と UIStackView を追加して、コンポーネントを動的に追加できるようにします。

  2. 3つの UIView ( HeaderView 、 StatsView 、 PostView )に分けます。

  3. 各コンポーネントの xib ファイル と UIView ファイル を作成します。

  4. 各 UIView で全てのスタイルを管理します。

  5. UI をデータで更新する関数を UIView に作成します。

  6. 各 UIView にプロトコル(デリゲート)を追加して、 UI の更新やアクションのイベントを ViewController が取得できるようにします。

コーディングの時間

新しいiOSプロジェクトを作成しましょう。各ステップを進める前に、ここ(https://github.com/xmhafiz/SlimViewControllerDemo)にアップロードされている完成したプロジェクトを参考にしてください。

1. ViewController に StackView を追加して、コンポーネントを動的に追加できるようにする。

ViewController.swift を開き、 UIStackView() と UIScrollView() の変数をプログラムで追加します。まず、 UIScrollView を追加し、 superView の端に固定します。次に、 scrollView の中に UIStackView を追加し、端も固定します。また、 stackView の幅の制約を scrollView に設定します。

import UIKit

class ViewController: UIViewController {
    lazy var scrollView = UIScrollView()
    lazy var mainStackView: UIStackView = {
        let stackView = UIStackView()
        stackView.axis = .vertical
        return stackView
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupView()
    }
    
    func setupView() {
        scrollView.pinToEdges(inView: view)
        mainStackView.pinToEdges(inView: scrollView)
        mainStackView.widthAnchor.constraint(equalTo: scrollView.widthAnchor).isActive = true
    }
}

extension UIView {
    func pinToEdges(constant: CGFloat = 0, inView superview: UIView) {
        superview.addSubview(self)
        self.translatesAutoresizingMaskIntoConstraints = false
        self.topAnchor.constraint(equalTo: superview.topAnchor, constant: constant).isActive = true
        self.bottomAnchor.constraint(equalTo: superview.bottomAnchor, constant: constant).isActive = true
        self.leadingAnchor.constraint(equalTo: superview.leadingAnchor, constant: constant).isActive = true
        self.trailingAnchor.constraint(equalTo: superview.trailingAnchor, constant: constant).isActive = true
    }
}

プロジェクトを実行しても、今のところ何も変わりません。

2 .3つの UIView ( HeaderView 、 StatsView 、 PostView )に分解します。

このステップでは、3つの UIView Swift ファイルと3つの xib ファイル を作成し、それらをリンクします。

a. HeaderView.swift と HeaderView.xib

必ず、"ファイルの所有者" と "クラス" 名をリンクしてください。また、 HeaderView.swift で作成した全ての  IBOutlets をリンクしてください。

import UIKit

class HeaderView: UIView {
    @IBOutlet weak var titleLabel: UILabel!
    @IBOutlet weak var subtitleLabel: UILabel!
    @IBOutlet weak var profileImageView: UIImageView!
    @IBOutlet weak var editButton: UIButton!
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        commonInit()
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    
    func commonInit() {
        let xibFileName = "HeaderView" // xib extension not included
        let view = Bundle.main.loadNibNamed(xibFileName, owner: self, options: nil)![0] as! UIView
        self.addSubview(view)
        view.frame = self.bounds
        setupView()
    }
    
    func setupView() {
        editButton.clipsToBounds = true
        editButton.layer.cornerRadius = editButton.frame.height/2
        profileImageView.clipsToBounds = true
        profileImageView.layer.cornerRadius = profileImageView.frame.height/2
    }
    
    func update(profile: DemoProfile) {
        profileImageView.image = UIImage(named: profile.avatarImageName)
        titleLabel.text = profile.name
        subtitleLabel.text = profile.role
    }
}

b. StatsView.swift と StatsView.xib (前回と同じで、「ファイルの所有者」とアウトレットをリンクします。)

//  StatsView.swift
import UIKit

class StatsView: UIView {
    @IBOutlet weak var viewContainerView: UIView!
    @IBOutlet weak var readContainerView: UIView!
    @IBOutlet weak var totalViewValueLabel: UILabel!
    @IBOutlet weak var totalReadValueLabel: UILabel!
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        commonInit()
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    
    func commonInit() {
        let xibFileName = "StatsView" // xib extension not included
        let view = Bundle.main.loadNibNamed(xibFileName, owner: self, options: nil)![0] as! UIView
        self.addSubview(view)
        view.frame = self.bounds
        setupView()
    }
    
    func setupView() {
        viewContainerView.clipsToBounds = true
        viewContainerView.layer.cornerRadius = 10
        readContainerView.clipsToBounds = true
        readContainerView.layer.cornerRadius = 10
    }
    
    func updateContent(profile: DemoProfileStore) {
        totalViewValueLabel.text = profile.totalViews
        totalReadValueLabel.text = profile.totalReads
    }
}

c. 最後に作成する UIView ファイル は、 PostView.swift と PostView.xib です。

//
//  PostView.swift
import UIKit

class PostView: UIView {
    @IBOutlet weak var titleLabel: UILabel!
    @IBOutlet weak var subtitleLabel: UILabel!
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        commonInit()
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    
    func commonInit() {
        let xibFileName = "PostView" // xib extension not included
        let view = Bundle.main.loadNibNamed(xibFileName, owner: self, options: nil)![0] as! UIView
        self.addSubview(view)
        view.frame = self.bounds
    }
    
    func updatePost(title: String, description: String) {
        titleLabel.text = title
        subtitleLabel.text = description
    }
}

3. 全てのスタイルは各 UIView で管理されます。

4. UI をデータで更新する関数を UIView に作成します。

使用する適切なオブジェクトを渡すようにしてください。このプロジェクトでは、必要な全てのプロパティを持つ DemoProfile を使用します。

5.各 UIView にプロトコル(デリゲート)を追加して、 UI の更新やアクションのイベントを Controller が取得できるようにします。

このステップでは、UIView コンポーネントが何らかのアクションや変更に対して ViewController に通知できるようにするために、 Swift プロトコルを使用します。

UIView コンポーネントは独立したクラスなので、 ViewController は内部で起こったことを何も知りません。

今回のケースでは、 "Edit Profile" ボタンがタップされたときに Controller に通知するためのプロトコル(デリゲート)を HeaderView に追加するだけです。

a. まず、 HeaderView.swift に HeaderViewDelegate というプロトコルを追加します。

protocol HeaderViewDelegate {
    func didTapEditButton()
}

class HeaderView: UIView {
    //...
    // add delegate variable
    var delegate: HeaderViewDelegate?
    //...
  
    // update this function with button target
    func setupView() {
        // ...
        // add actions
        editButton.addTarget(self, action: #selector(self.handleEditAction), for: .touchUpInside)
    }
  
    @objc func handleEditAction() {
        delegate?.didTapEditButton()
    }
}

b. 次に、上に示したように、 didTapEditButton() というメソッドを追加します。そして、ボタンのターゲット・アクションがデリゲート・メソッド(19行目の上のコード)を呼び出すようにします。

c. ViewController クラスで、 HeaderViewDelegate を実装し、 HeaderView オブジェクトの .delegate を self に設定して、実装メソッドで呼び出すようにします(下図)。

ViewController.swift の一番下に HeaderViewDelegate の実装を追加して、エラーを回避します。

extension ViewController: HeaderViewDelegate {
    func didTapEditButton() {
        print("Edit button is tapped!")
    }
}

最後に、この3つのコンポーネントを stackView に含めるように ViewController を更新しましょう。更新後の ViewController は以下のようになります。

//
//  ViewController.swift
import UIKit

class ViewController: UIViewController {

    lazy var headerView = HeaderView()
    lazy var summaryView = StatsView()
    lazy var postView = PostView()
    
    lazy var scrollView = UIScrollView()
    lazy var mainStackView: UIStackView = {
        let stackView = UIStackView()
        stackView.axis = .vertical
        stackView.addArrangedSubview(headerView)
        stackView.addArrangedSubview(postView)
        stackView.addArrangedSubview(summaryView)
        return stackView
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupView()
        // mock fetching data and populate into views
        setupData()
    }
    
    func setupView() {
        scrollView.pinToEdges(inView: view)
        mainStackView.pinToEdges(inView: scrollView)
        mainStackView.widthAnchor.constraint(equalTo: scrollView.widthAnchor).isActive = true
        
        // setup delegate
        headerView.delegate = self
    }
    
    func setupData() {
        let profile = DemoProfile()
        // update header
        headerView.update(profile: profile)
        // update text
        postView.updatePost(title: profile.postTitle, description: profile.postDescription)
        // update summary
        summaryView.updateContent(profile: profile)
    }
}

extension ViewController: HeaderViewDelegate {
    func didTapEditButton() {
        print("Edit button is tapped!")
    }
}

ミッション完了!

おめでとう!これで全てのステップが完了し、プロジェクトを実行する準備ができました。 ViewController.swift は52行しかありません。やったね🎉。

完全なソースコードはここ(https://github.com/xmhafiz/SlimViewControllerDemo)からダウンロードできます。既存のプロジェクトに実装することで、(将来のモジュールや機能によって)よりすっきりし、より拡張しやすくなります。

読んでくれてありがとう。フィードバックは大歓迎です。

"学ぶこと、それが私たちのスキルを成長させる方法です"

参考記事

https://docs.swift.org/swift-book/LanguageGuide/Protocols.html

https://github.com/xmhafiz/SlimViewControllerDemo

【翻訳元の記事】

How To Fix Your Fat ViewController
https://medium.com/geekculture/slim-uiviewcontroller-break-into-multiple-uiview-components-78d7a67d7468

Discussion