業務でSwiftUIを使って画面構築してみた話

業務の中でSwiftUIを使って画面構築出来る機会がありましたので、実際に構築し終えての所感をまとめておきたいと思います。

やったこと

昨年、アプリの対応バージョンがiOS13以上となったので、SwiftUIが導入できるようになりました。 ちょうどUITableViewControllerを利用して構築されていたヘルプページの改修を予定していたので、こちらをSwiftUIへ置き換えてみました。 簡単に構成を示しておきます。

画面構成

f:id:Rakuma:20210413104232p:plain
画面構成

今回作成したViewは、Objective-Cで作成したUIKitのViewとSwiftのUIKitのViewに挟まれた場所にありました。 この構成が後に悩みのタネとなります。

クラス構成

MVPベースに作成していますが、Presenterの持つBindingされた値をSwiftUIのViewクラスへ渡すような構成となっているため、実質的にはMVVMのようになっています。

f:id:Rakuma:20210413104249p:plain
クラス構成

今回作成した画面の遷移元がUINavigationControllerを利用したViewControllerだったため、SwiftUIのNavigationViewを利用しての画面遷移実装ができませんでした(後述)。 ですので、画面遷移の実装はViewControllerで行わせつつ、レイアウト部分をSwiftUIのViewクラスで実装しています。

🥰 良い点

コードがシンプル

今回作成したViewに関連する箇所のコードのみを抽出すると、以下のようになっていました。

  • UIKit: ViewController = 60行(UI関連のみ) + Storyboard(XML)
  • SwiftUI: 75行(Preview除く)

Storyboardを使わなくなる分、SwiftUIクラス側の記述が大きくなるかと想像していましたが、2〜3割くらいコードが増えるだけで済んでいます。

作業においては、レイアウト作成中にStoryboard/Xib ⇔ Swiftファイルを行き来する必要がないため、集中しやすいと感じました。

純粋なSwiftのコードで記述できるので、複数人で同一Viewを編集するような状況になってもStoryboard/Xibのような厄介なコンフリクトが起きる心配もないですね。

Previewでレビュー効率を上げる

SwiftUIリリース時に導入されたPreviewの機能では、複数のPreviewを表示できます。 APIのレスポンスに応じて表示の切り替えを行うViewでは各StateごとのPreviewを表示することで開発効率を上げることができます。 また、レビュー時にも各Previewを参照することでレイアウトを確認できるので、モックの準備などが不要になり非常に効率的かと思います。

struct Sample_Previews: PreviewProvider {

    static var previews: some View {
        // プレビューを3つ並べる
        SampleView(presenter: successPresenter)
        SampleView(presenter: loadingPresenter)
        SampleView(presenter: failedPresenter)
    }

    // ローディング成功時のプレビュー
    static var successPresenter: SamplePresenter {
        let userInterface = SampleViewController()
        let presenter = SamplePresenter(userInterface: userInterface)
        presenter.state = .success(itemList: itemList)
        return presenter
    }

    // ローディング中のプレビュー
    static var loadingPresenter: SamplePresenter {
        let userInterface = SampleViewController()
        let presenter = SamplePresenter(userInterface: userInterface)
        presenter.state = .loading
        return presenter
    }

    // ローディング失敗時のプレビュー
    static var failedPresenter: SamplePresenter {
        let userInterface = SampleViewController()
        let presenter = SamplePresenter(userInterface: userInterface)
        presenter.state = .failed
        return presenter
    }

    // サンプル表示用のデータモデル
    static let itemList: [Item] = [
        Item(id: 1, title: "問い合わせ1")
    ]
}
表示例

f:id:Rakuma:20210413104309p:plain
Preview表示例

😵 困る点

効率的な記述のできるSwiftUIですが、一方で既存アプリへの導入時にはちょっと困ったこともあります。

UINavigationControllerとNavigationViewの互換性がない

UINavigationController&ViewControllerで構成されたViewからSwiftUIで構成されたViewへ遷移させたい場合、UINavigationControllerの内容をNavigationViewに引き継ぐことができません。

UINavigationControllerを使用したViewからNavigationViewを使用したSwiftUIのViewへ単純に遷移させてしまうと、NavigationBarが2重に表示されるような状態になります。

f:id:Rakuma:20210413104329p:plain
NavigationView

このようなケースにおいては、SwiftUIのNavigationViewとNavigationLinkを使わず、ViewControllerからUIHostingControllerを利用してSwiftUIのViewを呼び出していくのがベターかと思います。

Combine前提にしている処理が多々

例としてアラートのメソッドを挙げますが、こちらにはBinding型の引数があり、表示のコントロールを行うのにはController層、Presenter層などからBindingされた値を渡してあげる必要があります。

public func alert<Item>(item: Binding<Item?>, content: (Item) -> Alert) -> some View where Item : Identifiable
public func alert(isPresented: Binding<Bool>, content: () -> Alert) -> some View 

UIKitだとViewControllerからpresentしてあげるだけでしたが、要領が変わっているので設計上注意が必要です。

SwiftUIでは、多くのクラスでデータバインディングを前提とした構成を採用しているため、現状ではMVVMが一番スムーズに導入出来るアーキテクチャなのではないかと思います。 (ラクマアプリはMVPアーキテクチャベースとなっているため、このViewだけイレギュラーな構成となってしまいました😵)

UIKitではデフォルトで用意されている部品が一部ない

  • ActivityIndicator
  • Accessory付きのButton
  • PageControl(iOS13で非対応) etc...

UIKitに存在した上記の部品たちはSwiftUIでは用意されていないので、UIKitのクラスをSwiftUIで使えるようラップして使えるようにするか、自作する必要があります。 下記のようなLoadingViewはSwiftUI用に新規作成しています。

f:id:Rakuma:20210413104348p:plain
LoadingView

struct LoadingView: View {
    var text: String = "読み込み中..."

    var body: some View {
        ZStack {
            Color(UIColor.clear)
            VStack {
                ActivityIndicator(style: .large)
                Text(text)
                    .bold()
                    .foregroundColor(Color(ColorPalette.textWhite))
                    .multilineTextAlignment(.center)
            }.frame(minWidth: 130, idealWidth: 130, minHeight: 130, idealHeight: 130)
            .compositingGroup()
            .background(RoundedRectangle(cornerRadius: 10)
                            .fill(ColorPalette.indicatorBackground))
            .shadow(radius: 6)
            .edgesIgnoringSafeArea(.all)
        }
        .transition(AnyTransition.opacity.animation(.easeOut(duration: 0.3)))
    }
}

struct ActivityIndicator: UIViewRepresentable {

    typealias UIViewType = UIActivityIndicatorView

    var style: UIActivityIndicatorView.Style = .medium

    func makeUIView(context: Context) -> UIActivityIndicatorView {
        let indicator = UIActivityIndicatorView(style: style)
        indicator.color = .white
        return indicator
    }

    func updateUIView(_ uiView: UIActivityIndicatorView, context: Context) {
        uiView.startAnimating()
    }
}

iOS13⇔iOS14間で表示差分がある

細かなところですが、iOS13とiOS14の間で表示の異なる部分がありました。下記に一例を示します。 このあたりは実装時の確認や検証をしっかり行っていく必要がありますね。

表示時のアニメーションの有無

iOS13でListを使用した画面を表示する場合、デフォルトでのアニメーションが挿入されませんでした。

iOS13 iOS14
f:id:Rakuma:20210413104406g:plain
Animation(iOS13)
f:id:Rakuma:20210413104434g:plain
Animation(iOS14)
Listのデザイン

iOS13ではListのgroupごとにカード型のような表示となっていますが、iOS14では従来のUITableViewの表示に準ずるデザインとなっています。

iOS13 iOS14
f:id:Rakuma:20210413104500p:plain
List(iOS13)
f:id:Rakuma:20210413104520p:plain
List(iOS14)

Previewが重い

XcodeでのPreview利用時にObjective-Cを利用しているライブラリのビルドが頻繁に走るため、表示の更新に分単位の時間がかかることがありました。 (このあたりはM1搭載のMacでは速くなっているのでしょうか🤔)

あとがき

既存アプリへのSwiftUIの導入にはいくつかの障壁がありますが、新規作成のViewであったり独立性の高い機能であればSwiftUIを積極的に選択肢に入れるのもアリかと思います👍 また、今後の導入に備えて既存のObjective-Cのコードを駆逐しておいたり、MVVMなどのアーキテクチャを導入しておいたりしておくことも大事かと思います。 導入のメリットも大きいですし、今後のUI構築はSwiftUIへシフトしていく可能性も高いと思いますので、積極的に使っていきたいですね。