LIVESENSE ENGINEER BLOG

リブセンスエンジニアの活動や注目していることを発信しています

SwiftUIを導入した話

2021年9月からマッハバイトに業務委託として参画している草間と申します。
iOSエンジニアとして参画し、今ではandroid・Web側も担当するようになり楽しく業務しています。

今回は、マッハバイトiOSアプリで静的な画面をSwiftUIに置き換えた話をします。

SwiftUIに置き換えることになった

マッハバイトのiOSアプリは2019年初頭にリリースされ多くの機能追加、改修を経て今現在もサービスを提供しています。
2022年の秋頃、次はどのようなことがやりたいかをチーム内で話し合う機会がありました。
この話し合いで新しい技術を取り入れたいという声が上がりました。
今回は上がった候補から「簡単な画面をSwiftUI化する」ことをピックアップし、2022年冬SwiftUI化はスタートしました。

SwiftUIへ移行する

SwiftUIへの移行は次の条件を決め対応していきました。

  1. 対象は静的な画面のみとする
  2. 既存の画面遷移の仕組みはそのまま利用する
  3. 既存のViewControllerのライフサイクルを利用している仕組みはそのまま利用する
  4. コンポーネント化したカスタムViewはそのまま利用する(ラップして利用する)

ViewControllerの用意

まずは、既存の画面遷移の仕組みをそのまま利用するためUIHositngViewControllerのviewプロパティを表示するベースのViewControllerを用意しました。
クラスの全容は下記に記載します。

import SwiftUI
import UIKit

class BaseViewController<T: View>: UIViewController {
    private let rootView: T

    init(rootView: T) {
        self.rootView = rootView
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        let hostingVC = UIHostingController(rootView: rootView)
        addChild(hostingVC)
        hostingVC.didMove(toParent: self)
        view.addSubview(hostingVC.view)
        hostingVC.view.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            hostingVC.view.topAnchor.constraint(equalTo: view.topAnchor),
            hostingVC.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            hostingVC.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            hostingVC.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])
        hostingVC.view.updateConstraints()
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        view.updateConstraints()
    }
}

UIHostingViewControllerをpushで表示する方法もありますが移行条件に合致しないため採用しませんでした。
UIViewControllerRepresentableでSwiftUIからUIViewControllerを使えたようですが、今回は導入条件や既存との相性から採用しませんでした。

SwiftUIを表示するときは次のように上記のクラスを継承したクラスを作成しました。

import SwiftUI
import UIKit

class HogeHogeSwiftUIViewController: BaseViewController<HogeHogeSwiftUIView> {
    
    init() {
        super.init(rootView: HogeHogeSwiftUIView())
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

こうすることで画面遷移が次のようにできます。

let vc = HogeHogeSwiftUIViewController()
self.navigationController?.pushViewController(vc, animated: true)

カスタムViewを使いやすくするための工夫

コンポーネントとして実装しているカスタムViewはUIViewRepresentableを使って次のような実装をしています。

import SwiftUI
import UIKit

struct CustomViewSwiftUIView: UIViewRepresentable {

    private let customView = CustomView()

    func makeUIView(context: Context) -> CustomView {
        customView
    }

    func updateUIView(_ uiView: CustomView, context: Context) {
        // なにか処理
    }
}

このあたりは調べればいくらでも情報が出てくる部分なので細かい部分は割愛します。
マッハバイトでは、カスタムViewをある程度実装しているかつ、共通パーツとして利用するパターンが多いので上記の対応だと利用しづらい部分がどうしてもありました。

例えばカスタムLabelがあったとして

class CustomLabel: UILabel { ... }

上記のCustomViewSwiftUIViewの実装だと文字色を変えるだけで複製が必要になってしまいます。
できればSwiftUI上で変更したいですよね。
なので次のような実装にしました。

import SwiftUI
import UIKit

struct CustomLabelSwiftUIView: UIViewRepresentable {

    private var text: String
    private let customLabel = CustomLabel()

    init(_ text: String? = nil) {
        self.text = text
    }

    func makeUIView(context: Context) -> CustomLabel {
        customLabel
    }

    func updateUIView(_ uiView: CustomLabel, context: Context) {
        // リサイズ
        customLabel.sizeToFit()
    }
}

// メソッドチェーンできるようにselfを返す
extension CustomLabelSwiftUIView {

    func font(_ font: UIFont?) -> IconLabelSwiftUI {
        customLabel.font = font
        return self
    }

    func textColor(_ color: UIColor?) -> IconLabelSwiftUI {
        customLabel.textColor = color
        return self
    }

    func backgroundColor(_ color: UIColor?) -> IconLabelSwiftUI {
        customLabel.backgroundColor = color
        return self
    }

    func textAlignment(_ alignment: NSTextAlignment) -> IconLabelSwiftUI {
        customLabel.textAlignment = alignment
        return self
    }
}

これでSwiftUI上でJavaのBuilderパターンのように実装できます。

CustomLabelSwiftUIView("HogeHoge")
    .font(UIFont.systemFont(ofSize: 24))
    .textAlignment(.center)
    .textColor(UIColor.red)

ここで渡すUIFontはSwiftUI側のFontにしたかったのですが、FontからUIFontを生成するAPIがなかったので今回は実装はしませんでした。
このためSwiftUIのファイルでUIKitをインポートする必要がありました。
UIFontからFontを生成することはできたので拡張するやり方もありましたが、今回はやりませんでした。

SwiftUIを導入してみて

静的な画面のみでしたがSwiftUIを導入するメリットは大きいと感じました。
個人的な感動ポイントは2点あります。

  1. レビュー時にマージンやフォントサイズの細かい数値部分の指摘がしやすいこと
  2. 画面内をブロックごとに別のファイルに分け、見通しの良いファイルが作れそうなこと

XIBやStoryboardの場合は、膨大なXMLを紐解くか手元でファイルを開いて確認するなどプルリク以外での対応が必要でした。
その手間が少なくできるのは良い点だと思います。
特にマージンなどの各Viewに対する制約のレビューがやりやすいことは、iOSアプリ開発者の皆さんは感動いただけるポイントじゃないかと思います。

最後に

このように静的な画面をSwiftUIに置き換えていきました。
実際はプロジェクトの構成の影響なのかSwiftUIオンリーのプロジェクトでは表示OKだったものがうまく表示できないなど苦労がありましたが、別の記事で検証なども加え改めて紹介したいと思います。