はじめに

システム開発部のKです。

SwiftUIでお馴染みのPreviewsをUIKit環境下で使っていくための実装を紹介いたします。Xcode15からはPreviewsの実装は以前と比べて格段に実装が容易になっております(後述)。ですが、開発現場によってはXcode14をまだまだ使う機会があるかと思いますので今回はXcode14での実装を解説していきます。

開発中のレイアウトをBuildせずにリアルタイムで確認できるのは大きなメリットになるかと思いますので参考にしてみてください。

開発環境

  • Xcode: 14.3.1
  • Swift: 5.8.1
  • macOS: 13

実装手順

カスタムUIViewの定義

例として、UIStackViewの中に”画像/タイトル/ボタン”を横に並べたシンプルなカスタムUIViewを作成します。

import UIKit

class MyView: UIView {
    
    private lazy var stackView: UIStackView = {
        let view = UIStackView()
        view.axis = .horizontal
        view.spacing = 5
        view.alignment = .center
        return view
    }()
    
    private lazy var imageView: UIImageView = {
        let view = UIImageView()
        view.image = UIImage(systemName: "folder")
        return view
    }()
    
    private lazy var titleLabel: UILabel = {
        let view = UILabel()
        view.text = "Title"
        return view
    }()
    
    private lazy var button: UIButton = {
        let view = UIButton()
        view.setTitle("Button", for: .normal)
        view.backgroundColor = .blue
        return view
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    
    private func commonInit() {
        // 各パーツをstackViewに追加
        stackView.addArrangedSubview(imageView)
        stackView.addArrangedSubview(titleLabel)
        stackView.addArrangedSubview(button)
        
        // stackViewを画面に配置。制約を追加。
        addSubview(stackView)
        stackView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            stackView.topAnchor.constraint(equalTo: topAnchor),
            stackView.bottomAnchor.constraint(equalTo: bottomAnchor),
            stackView.leadingAnchor.constraint(equalTo: leadingAnchor),
            stackView.trailingAnchor.constraint(equalTo: trailingAnchor)
        ])
    }
}

UIViewRepresentableプロトコルに準拠したクラスの定義

UIViewRepresentableプロトコルに準拠したラッパークラスを定義します。UIViewRepresentableはSwiftUIのコンポーネントであり、UIViewをSwiftUIのViewとして使うことができるようになるプロトコルです。

UIViewRepresentableに準拠したラッパークラスを経由することでPreviewsを使用できるようになります。

struct MyViewWrapper: UIViewRepresentable {

    func makeUIView(context: UIViewRepresentableContext<MyViewWrapper>) -> MyView {
        MyView() // Viewを初期化
    }

    func updateUIView(_ uiView: MyView, context: UIViewRepresentableContext<MyViewWrapper>) {
       // 必要であれば更新処理をここに書く
    }

}

func makeUIView(context: UIViewRepresentableContext<MyViewWrapper>) -> MyView: プレビューしたいViewをこのメソッドの中で初期化することでプレビューの対象になります。

func updateUIView(_ uiView: MyView, context: UIViewRepresentableContext<MyViewWrapper>): makeUIViewで生成したViewを更新できます。

PreviewProviderプロトコルに準拠したクラスの定義

PreviewProviderはSwiftUIのViewをキャンバス上に反映されるために使用されます。SwiftUI同様、previewsプロパティを定義し、クロージャの中でラッパークラスを初期化します。フレームサイズや仮想端末などもSwiftUIと同様に設定可能です。

struct MyViewPreview: PreviewProvider {
    static var previews: some View {
        MyViewWrapper()
        .previewDisplayName("My View") // ディスプレイの名称を指定
        .previewLayout(.fixed(width: 200, height: 44)) // 固定サイズを指定
    }
    
    static var platform: PreviewPlatform? = .iOS
}

ここで一度Previewを確認します。Xcodeでキャンパスを表示し、ViewモードをSelectableに設定します。

ImageViewの制約が不足しているため画像が伸びています。このように、Buildせずとも見た目がおかしいことに気づくことができます。レイアウトを修正するために画像に制約を加えます。

class MyView: UIView {
   
    …
    
    private func commonInit() {
        ...
        
        NSLayoutConstraint.activate([
            stackView.topAnchor.constraint(equalTo: topAnchor),
            stackView.bottomAnchor.constraint(equalTo: bottomAnchor),
            stackView.leadingAnchor.constraint(equalTo: leadingAnchor),
            stackView.trailingAnchor.constraint(equalTo: trailingAnchor),
            // ImageViewの制約を追加
            imageView.heightAnchor.constraint(equalTo: button.heightAnchor),
            imageView.widthAnchor.constraint(equalTo: imageView.heightAnchor) 
        ])
    }
}

このように、UIKit環境下でもPreviewを使うことでリアルタイムに確認できるようになりました。

更新処理の実装

タイトルやButtonのテキストなどを変更できるように更新処理を追記します。

更新処理をEnumのクラスとして定義

更新処理を定義したInputクラスをEnumで作成し、更新手順をcaseにそれぞれ定義します。

class MyView: UIView {
    
    enum Input {
        case updateImage(_ image: UIImage)
        case updateTitle(_ text: String)
        case updateButtonTitle(_ text: String)
        case updateButtonBgColor(_ color: UIColor)
    }
    …
}

構造体などを直接渡して更新する際はEnumでの定義は不要かもしれません。状況に応じて柔軟に対応したいところです。

このInputクラスを外部から受け取り更新処理を実行するための関数を作成します。

extension MyView {
    func apply(input: Input) {
        switch input {
        case .updateImage(let image):
            imageView.image = image
        case .updateTitle(let text):
            titleLabel.text = text
        case .updateButtonTitle(let text):
            button.setTitle(text, for: .normal)
         case .updateButtonBgColor(let color):
            button.backgroundColor = color
        }
    }
}

ラッパークラスで更新処理をプロパティで保持しupdateUIView()の中で実行

ラッパークラスのプロパティとして更新内容を配列で保持します。updateUIView()が初期化完了に実行され、配列にある更新処理を一つずつ実行します。

struct MyViewWrapper: UIViewRepresentable {
    // 更新内容の配列inputs
    let inputs: [MyView.Input]

    // 初期化時に更新内容を受け取る
    init(inputs: [MyView.Input] = []) {
        self.inputs = inputs
    }
    
    ... 

    func updateUIView(_ uiView: MyView, context: UIViewRepresentableContext<MyViewWrapper>) {
        // 一つずつ実行する
        inputs.forEach {
            uiView.apply(input: $0)
        }
    }
}

ラッパークラス初期化時に更新処理を渡す

ラッパークラス初期化時の引数inputsに更新処理の配列を渡します。

struct MyViewPreview: PreviewProvider {
    static var previews: some View {
        MyViewWrapper(
            inputs: [
                .updateTitle("タイトル")
                .updateButtonTitle("ボタン")
            ]
        )
        .previewDisplayName("My View")
        .previewLayout(.fixed(width: 200, height: 44)) 
    }
    
    static var platform: PreviewPlatform? = .iOS
}

配列に更新処理を追記することでリアルタイムにキャンパスに反映されます。

Liveモードで挙動を試す

Liveモードを使うとSwiftUIと同様にキャンパス上の仮想端末から操作が可能になります。

右のUIButtonをカスタムボタンに変えてみましょう。今回はタップ操作に合わせてサイズが拡大/縮小するカスタムボタンを実装します(実装コードは割愛)。

キャンパス下部のViewのモードをLiveに切り替えます。

Liveモードは仮想端末の幅一杯までViewが配置される仕様のため、小さいサイズで描画するためのViewでも端末幅まで広がってしまいます。

サイズを固定したい場合、ラッパークラスのサイズ指定を以下のように変更します。

struct MyViewPreview: PreviewProvider {
    static var previews: some View {
        MyViewWrapper(
            inputs: [
                .updateTitle("タイトル")
            ]
        )
        .previewDisplayName("My View")
        .frame(width: 200, height: 44) // Viewそのもののサイズを指定
        .previewLayout(.sizeThatFits) // プレビューの範囲をframeのサイズに合わせる
    }
    
    static var platform: PreviewPlatform? = .iOS
}

.previewLayout(.fixed(width: 200, height: 44))でも良いのでは?と思いましたが、この書き方はプレビューの範囲を指定するだけでView自体のサイズは変わりません。今回のようにViewのサイズを指定する際はframeを指定した後でプレビューの範囲を合わせる必要があります。

これでLiveモードでも指定のサイズで表示されます。

Xibからレイアウトをロードする場合でも実装方法は変わりません。

Xibだとすでにレイアウトはできていますが、ボタンの挙動などをBuildしなくても確認できる点から実装するメリットはあると感じました。

Xcode15の場合

Xcode15ではPreview macroが実装され、より簡単に実装できるようになりました。

#Preview {
     let button = UIButton(type: .system)
    button.setTitle("UIKit", for: .normal)
    
    return button
}

懸念点

実装ミスがあるソースコードがハイライトされないため修正箇所がわかりにくい

エラーが起きている場合、キャンパス上にエラーが表示されるだけでソースコードがハイライトされることはないため、自力で探す必要があります。上記コードを実装する際、AutoLayout制約のleadingActorleftActorを間違えており、気づくのに時間がかかってしまいました。

Previewsの更新に時間がかかる

大きなプロジェクトになると更新に時間がかかる可能性が高いです。UIKitでの開発に関係なく、Previewsを使用するには避けて通れない問題かと思います。この辺りは構成ファイルをマルチモジュール化し、個別にBuildすることで改善が見込める可能性があります。

参考記事



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる