69
49

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

UIViewRepresentable を理解して SwiftUI の足りないところを UIKit で補う

Last updated at Posted at 2021-11-30

概要

SwiftUI で開発していると、 UIKit で使っていた機能に対応するコンポーネントがなくて困るということがよく起こります。そんなときに SwiftUI の中で UIKit を使えるようにしてしまえばめちゃくちゃ便利じゃん...という素人の発想を実現してくれるのが UIViewRepresentable です。とくに SwiftUI があまり成熟していなかった iOS 13 をサポートしているアプリでは UIViewRepresentable に頼る場面が多くなるのではないでしょうか。

UIViewRepresentable の API は非常にシンプルなので既存の利用例をざっと見ただけでも使えてしまうのですが、一度ちゃんと理解しておこうと思って調べたのでまとめます。もしこの記事に修正・改善すべき点があればコメントで教えていただけると助かります。

検証環境は以下です。

  • Xcode 13.2 Beta 2
  • iOS 15.2

使い方

まずは UIViewRepresentable の基本的な使い方についてです。SwiftUI の中で UIView を使いたいと思ったときにやることは、

  • UIView をラップする UIViewRepresentable を作り、そのメソッドとして makeUIViewupdateUIView を実装する
  • 作った UIViewRepresentable を SwiftUI の View の中で呼び出す

の2つだけです。

UIViewRepresentable を作る方から見ていきます。まずは冷静に UIViewRepresentable のシグネチャを眺めてみましょう。

上記のドキュメントから必要な情報のみ抜粋したものを以下に示します。

public protocol UIViewRepresentable : View where Self.Body == Never {
    // どの型の UIView をラップするかを表す
    associatedtype UIViewType: UIView

    // ラップする UIView のインスタンスを作って返す
    func makeUIView(context: Self.Context) -> Self.UIViewType
    // データの更新に応じてラップしている UIView を更新する
    func updateUIView(_ uiView: Self.UIViewType, context: Self.Context)
}

どの UIView をラップするかが associatedtypeUIViewType として定義されており、これが protocol のメソッドのシグネチャにも使われています。UILabel をラップしたいときには UILabel を、UIScrollView をラップしたいときには UIScrollViewUIViewType に指定します。

開発者側での実装が必須なメソッドは2つです。
まず、makeUIView にて、ラップしたい UIViewType のインスタンスを生成して返します。例えば UILabelUIScrollView のイニシャライザを呼んで、必要な設定をしてからそれを return することになるでしょう。
updateUIView は UIViewRepresentable の状態が更新されるたびに呼ばれるメソッドです。ラップしている UIView のインスタンスが引数として渡されてくるので、更新されたデータや Environment を UIView に反映させるということを行います。

実際に使用例を見てみましょう。よくあるカウンター画面でカウントの表示に UILabel を使いたくなったときのことを考えます。念のため補足すると、この例は説明のための人工的なもので、同等のことを SwiftUI の Text で実現できるため UIViewRepresentable を使う必然性はありません。

Int 型の count を受け取って現在のカウントを表示する UILabel をラップする UIViewRepresentable を書いてみます。

struct CounterLabelRepresentable: UIViewRepresentable {
    // 受け取りたいデータをプロパティとして定義する
    let count: Int
    
    func makeUIView(context: Context) -> UILabel {
        UILabel()
    }
    
    func updateUIView(_ uiLabel: UILabel, context: Context) {
        uiLabel.text = "I am a UILabel and count is \(count)"
    }
}

UIViewRepresentable のシグネチャと見比べると、今回の CounterLabelRepresentable は associatedtype の UIViewTypeUILabel を指定していることがわかると思います。makeUIViewUILabel のインスタンスを生成して返し、updateUIView で現在の count を表示に反映させています。
表示に必要なデータである count はプロパティとして定義します。ここで、プロパティは @Binding@State にもできますが、この例は受け取った count を表示するだけの受動的な View なのでふつうのプロパティにしています。

続いて、この CounterLabelRepresentable を実際に使ってみましょう。

struct CounterView: View {
    @State var count: Int = 0
    
    var body: some View {
        VStack(alignment: .leading) {
            Button(action: { count += 1 }) {
                Text("Increment")
            }
            
            // 作った UIViewRepresentable を呼び出すだけ
            CounterLabelRepresentable(count: count)
                .frame(height: 44)
        }
        .padding()
    }
}

注目したいのはCounterLabelRepresentable が UIKit の UILabel をラップしているにも関わらず、呼び出し側からは単なる SwiftUI の View であるかのように見えていることです。もちろん modifier もつけることができ、ここでは .frame で高さを指定しています。
UIViewRepresentable の利用者側からはその実装が UIView であることを意識する必要がないというのはとてもよい設計だと思います。

この画面は以下のように動作します。

counter.gif

各メソッドとライフサイクル

基本的な使い方がわかったところで、UIViewRepresentable の各メソッドについてもう少し詳しく調べてみます。UIViewRepresentable を理解する上での重要な要素に Coordinator がありますが、これについてはのちほど説明するのでここでは Coordinator が関わらないメソッドのみ見ることにします。

以下では UIViewRepresentable のライフサイクルという概念が出てきますが、これは UIViewRepresentable に対応する View が生成されてから消去されるまでのことを表していて、UIViewRepresentable の protocol に準拠している struct の寿命とは関係がないことに注意してください。基本的に View は生成されると非表示になるまで生き続けますが、その View を記述する struct は再描画のたびに作り直されます。
このあたりの SwiftUI の View のライフサイクルの話は難しくて自分が完全に理解できているか怪しいのですが、以下の WWDC の2つのセッションを見れば概要は掴めると思います。

func makeUIView

makeUIView は UIViewRepresentable のライフサイクルの中で一度だけ最初に呼ばれ、ラップする対象の UIView を生成します。UIView のインスタンスを生成したら、一度だけ行えばよい、データに関わらず不変な設定処理などをここに書くのがよいでしょう。例えば、UILabel をラップする場合はフォントサイズやカラーの設定などが考えられます。また、AutoLayout を設定する必要がある場合もここでやることが多いでしょう。

func updateUIView

updateUIViewmakeUIView が呼ばれた直後に一度、その後は View の状態が更新されるたびに呼ばれます。その性質上、データを View に反映させる責任は makeUIView ではなく updateUIView にあります。もちろん変化することがないデータの反映や、データが変化する場合も初期データの反映だけは makeUIView で行うことが可能ですが、いずれにしても makeUIView の直後に updateUIView が呼ばれるのでデータの反映は updateUIView のみで行うのがわかりやすいと思います。

static func dismantleUIView

dismantleUIView は UIViewRepresentable のライフサイクルの終わりに呼ばれ、もしなんらかの後処理が必要であればここで行います。subscription の解除や、この View が消えることをきっかけにアプリの他の画面で何かしたい場合の通知などが考えられます。
ちなみに、dismantle というのは取り外すとか分解するみたいな意味らしいです。

各メソッドの呼び出しタイミング

ここまでに上げたメソッドがいつ呼び出されるかを検証してみましょう。検証に使う例として、先ほどのカウンターを以下のように変えたものを用意します。いくつかの変更を同時に加えていますが、いずれも小さな変更です。

  • CounterLabelRepresentable の各メソッドや親 View の body にデバッグ出力を追加
  • 中身の UIView のライフサイクルも知りたいのでラップする UIView を UILabel から自前の MyUILabel に変更。機能は同じだが init / deinit 時にデバッグ出力を追加
  • UIViewRepresentable の表示 / 非表示を切り替える Toggle を追加
struct CounterView: View {
    @State var count: Int = 0
    @State var showingLabel: Bool = true

    var body: some View {
        let _ = print("Parent \(#function)")

        VStack(alignment: .leading) {
            Toggle(isOn: $showingLabel) {
                Text("Showing Label")
            }

            Button(action: { count += 1 }) {
                Text("Increment")
            }
            
            if showingLabel {
                CounterLabelRepresentable(count: count)
                    .frame(height: 44)
            }
        }
        .padding()
    }
}

struct CounterLabelRepresentable: UIViewRepresentable {
    let count: Int
    
    init(count: Int) {
        print("CounterLabelRepresentable \(#function)")
        self.count = count
    }
    
    func makeUIView(context: Context) -> MyUILabel {
        print("Representable \(#function)")
        return MyUILabel()
    }
    
    func updateUIView(_ uiLabel: MyUILabel, context: Context) {
        print("CounterLabelRepresentable \(#function)")
        uiLabel.text = "I am a UILabel and count is \(count)"
    }
    
    static func dismantleUIView(_ uiLabel: MyUILabel, coordinator: ()) {
        print("CounterLabelRepresentable \(#function)")
    }
}

class MyUILabel: UILabel {
    override init(frame: CGRect) {
        print("UILabel \(#function)")
        super.init(frame: frame)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    deinit {
        print("UILabel \(#function)")
    }
}

動作は Toggle が加わったこと以外は同じです。

toggled_counter.gif

以下、いくつかの操作に対してどのメソッドが呼ばれるかをデバッグ出力から見ていきます。

表示時

まず、初回の表示時のデバッグ出力を見てみます。

Parent body
CounterLabelRepresentable init(count:)
CounterLabelRepresentable makeUIView(context:)
UILabel init(frame:)
CounterLabelRepresentable updateUIView(_:context:)

親 View の body が呼ばれ、body に含まれる CounterLabelRepresentable struct の init が呼ばれています。その後、makeUIView が呼ばれ、実体の UILabel のインスタンスが生成されています。最後に、updateUIView が呼ばれていることがわかります。データ更新時だけでなく初回の表示時にも updateUIView が呼ばれることは UIViewRepresentable を使う上で知っておく必要があります。

更新時

続いて、Increment ボタンをタップして count を変化させます。

Parent body
CounterLabelRepresentable init(count:)
CounterLabelRepresentable updateUIView(_:context:)

count が変化することにより親 View の body が再評価され、updateUIView が呼ばれて表示が変化します。ここで init により CounterLabelRepresentable の struct は初期化されていますが、その裏にある UILabel は作り直されず、既存のインスタンスが使いまわされていることがわかります。UIView のインスタンス生成は View の struct と比べてコストが高いため、パフォーマンスを落とさないようにインスタンスが使い回される設計になっているのだと思います。

非表示時

続いて、Toggle をタップして CounterLabelRepresentable を非表示にしてみます。

Parent body
CounterLabelRepresentable updateUIView(_:context:)
CounterLabelRepresentable dismantleUIView(_:coordinator:)
UILabel deinit

非表示になったことにより dismantleUIView が呼ばれ、その後裏の UILabeldeinit されています。このあと再び Toggle をタップして CounterLabelRepresentable を表示すると、初回表示と同じように CounterLabelRepresentableUILabel が生成されます。裏の UIView ができるだけ使いまわされると言っても、さすがに非表示 -> 表示するとインスタンスは作り直されるようです。また、意外だった点として非表示になる時も updateUIView が呼ばれるようです。

親 View のみ再描画時

ここまでは CounterLabelViewRepresentable に関わる状態が変化したときの振る舞いを見てきましたが、親 View の body は再描画されるけど CounterLabelViewRepresentable が依存するデータには変化がないときにどのメソッドが呼ばれるのかも気になります。

CounterLabelViewRepresentable に関係ないデータとして、画面の背景色を親 View の状態として持たせて Toggle で更新できるようにしましょう。追加の差分のみを示します。

struct CounterView: View {
    @State var backgroundColored: Bool = false

    var body: some View {
        // ...
        VStack(alignment: .leading) {
            Toggle(isOn: $backgroundColored) {
                Text("Coloring Background")
            }
            
            // ...
        }
        // ...
        .background(backgroundColored ? Color.blue.opacity(0.2) : Color.clear)
    }
}

動作は以下のようになります。

colored_counter.gif

ここで、背景色の有無を切り替えたときのデバッグ出力は以下です。

Parent body
CounterLabelRepresentable init(count:)

@State が更新されたことによって親 View の body の再評価がされ、それに伴い CounterLabelRepresentableinit で初期化されています。ただし count を変化させたときと違って updateUIView が走らないことがわかります。この例では CounterLabelRepresentable が依存するデータが変化していないので再描画の必要はなく、パフォーマンスの観点からは望ましい振る舞いが実現されていると言えます。SwiftUI は各 View がどのデータに依存しているのかを把握し、データの更新が発生するとそのデータを使っている View のみを再描画するということだと思います。

Environment

ここまでは UIViewRepresentable の表示に必要なデータを引数として渡す場合について見てきました。実は、通常の SwiftUI の View と同じように UIViewRepresentable は Environment からも値を受け取ることができます。

カウンターの例を少し書き変えて、親 View から CounterLabelRepresentabledisabled modifier をつけて、値を切り替えられるようにします。 CounterLabelRpresentable の側では、引数の updateUIView の引数の contextenvironment.isEnabled からその値が取得できるので、それに応じてテキストの色を変化させます。 disabledtrue の時はテキストの色を薄くすることにしましょう。

struct CounterView: View {
    // ...
    @State var enabled: Bool = true

    var body: some View {
        VStack(alignment: .leading) {
            Toggle(isOn: $enabled) {
                Text("Enabling Label")
            }

            // ...

            if showingLabel {
                CounterLabelRepresentable(count: count)
                    .frame(height: 44)
                    .disabled(!enabled)
            }
        }
        // ...
    }
}

struct CounterLabelRepresentable: UIViewRepresentable {
    func updateUIView(_ uiLabel: MyUILabel, context: Context) {
        // ...
        uiLabel.textColor = context.environment.isEnabled ? .black : .gray
    }
}

動作させてみると、以下のように Environment の変化が表示に反映させていることがわかります。ここで、現在の Environment を UIViewRepresentable に反映するのも makeUIView ではなく updateUIView で行う必要があることに注意してください。

disabled_counter.gif

disabled を切り替えたときのデバッグ出力は以下で、Environment が変化すると引数の値が変わったときと同じようにメソッドが呼ばれていることがわかります。

Parent body
CounterLabelRepresentable init(count:)
CounterLabelRepresentable updateUIView(_:context:)

Coordinator

ここまで見てきた CounterLabelRepresentable は SwiftUI の親 View から情報を渡されるだけの受動的なコンポーネントでしたが、UIViewRepresentable からなんらかのアクションを起こしたい場合は Coordinator という仕組みが必要になります。

簡単な例として、カウンター画面の Increment ボタンを UIButton で書き直す例を考えてみます。以下のようになるでしょう。

struct IncrementButtonRepresentable: UIViewRepresentable {
    let onTapped: () -> Void

    init(onTapped: @escaping () -> Void) {
        self.onTapped = onTapped
    }

    func makeUIView(context: Context) -> UIButton {
        let button = UIButton(type: .system)
        button.setTitle("I am a UIButton and Increment", for: .normal)

        // TODO: onTap を呼んで count を increment する

        return button
    }

    func updateUIView(_ uiButton: UIButton, context: Context) {}
}

コード中に TODO としてコメントしたように、現状のコードだと親 View から渡されたボタンタップ時のアクション onTapped を発火させる方法がありません。最初に思いつくのは以下のように UIViewRepresentable にアクションを定義してしまうことでしょう。しかし、このコードはコンパイルエラーになってしまいます。

struct IncrementButtonRepresentable: UIViewRepresentable {
    func makeUIView(context: Context) -> UIButton {
        // ...
        button.addTarget(self, action: #selector(didTap), for: .touchUpInside)
        // ...
    }

    // ...

    // ❌ @objc can only be used with members of classes, @objc protocols, and concrete extensions of classes
    @objc
    func didTap() {
        onTapped()
    }
}

コンパイルエラーの直接の原因は struct には @objc メソッドを定義できないことですが、仮に文法上許されていたとしても UIViewRepresentable の struct はいつ親 View の再描画により作り直されるかわからない一時的な存在なのでアクションの定義先としては不適切でしょう。

このこと考えると、ラップする UIView と同じライフサイクルを持ち、かつ @objc メソッドが定義可能な class にアクションを定義したいです。そんな都合のよいものないよな...と思いきやちゃんと用意されていて、それが Coordinator です。

Coordinator は UIViewRepresentable がラップする UIView のデリゲートとして働きます。このデリゲートというのは UIView のアクションに対応して何かするものというくらい意味で、UIScrollViewDelegateUISearchBarDelegate のような Delegate と名のつく protocol であることもありますし、今回の例のように単に UIButton のアクションの定義先であることもあります。Delegate protocol に Coordinator を使う例としては、例えば以下の記事を参照ください。

Coordinator を使う上でやることは以下です。

  • Coordinator として使う class を定義する。UIViewRepresentable にネストさせて Coordinator という名前で定義することが多い
  • UIViewRepresentable の makeCoordinator メソッドの中で Coordinator を生成して返す
  • makeUIView / updateUIView メソッドの引数の context.coordinator で Coordinator が取得できるので Coordinator に行わせたい処理を設定する

IncrementButtonRepresentable を Coordinator を使うように修正すると、例えば以下のようになるでしょう。

struct CounterView: View {
    @State var count: Int = 0
    // ...

    var body: some View {
        // ...
        VStack(alignment: .leading) {
            // ...

            IncrementButtonRepresentable(onTapped: { count += 1 })
                .frame(height: 44)

            // ...
        }
        // ...
    }
}


struct IncrementButtonRepresentable: UIViewRepresentable {
    final class Coordinator {
        let onTapped: () -> Void
        
        init(onTapped: @escaping () -> Void) {
            self.onTapped = onTapped
        }
        
        @objc
        func didTap() {
            onTapped()
        }
    }
    
    let onTapped: () -> Void
    
    init(onTapped: @escaping () -> Void) {
        self.onTapped = onTapped
    }
    
    func makeCoordinator() -> Coordinator {
        return Coordinator(onTapped: onTapped)
    }
    
    func makeUIView(context: Context) -> UIButton {
        let button = UIButton(type: .system)
        button.setTitle("I am a UIButton and Increment", for: .normal)
        button.addTarget(context.coordinator, action: #selector(Coordinator.didTap), for: .touchUpInside)
        
        return button
    }
    
    func updateUIView(_ uiButton: UIButton, context: Context) {}
}

以下のようにきちんと動作するようになります。ちなみに、デバッグ出力を仕込むと、Coordinator は IncrementButtonRepresentable がラップする UIButton と同じタイミングで生成・消滅することがわかります。

uibutton_counter.gif

まとめ

  • UIViewRepresentable は SwiftUI の中で一部のみ UIView を使うためのしくみ
  • makeUIView は初期化時に一度だけ、updateUIView は初期化時とその後の状態更新時に呼ばれる。データを表示に反映するのは updateUIView で行う
  • 実体の UIView のインスタンスは状態更新のたびに作り直されるのではなく、非表示になるまで使い回される
  • UIViewRepresentable からなんらかのアクションを起こしたい場合は Coordinator を使う

参考

69
49
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
69
49

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?