概要
SwiftUI で開発していると、 UIKit で使っていた機能に対応するコンポーネントがなくて困るということがよく起こります。そんなときに SwiftUI の中で UIKit を使えるようにしてしまえばめちゃくちゃ便利じゃん...という素人の発想を実現してくれるのが UIViewRepresentable です。とくに SwiftUI があまり成熟していなかった iOS 13 をサポートしているアプリでは UIViewRepresentable に頼る場面が多くなるのではないでしょうか。
UIViewRepresentable の API は非常にシンプルなので既存の利用例をざっと見ただけでも使えてしまうのですが、一度ちゃんと理解しておこうと思って調べたのでまとめます。もしこの記事に修正・改善すべき点があればコメントで教えていただけると助かります。
検証環境は以下です。
- Xcode 13.2 Beta 2
- iOS 15.2
使い方
まずは UIViewRepresentable の基本的な使い方についてです。SwiftUI の中で UIView を使いたいと思ったときにやることは、
- UIView をラップする UIViewRepresentable を作り、そのメソッドとして
makeUIView
とupdateUIView
を実装する - 作った 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 をラップするかが associatedtype
の UIViewType
として定義されており、これが protocol のメソッドのシグネチャにも使われています。UILabel
をラップしたいときには UILabel
を、UIScrollView
をラップしたいときには UIScrollView
を UIViewType
に指定します。
開発者側での実装が必須なメソッドは2つです。
まず、makeUIView
にて、ラップしたい UIViewType
のインスタンスを生成して返します。例えば UILabel
や UIScrollView
のイニシャライザを呼んで、必要な設定をしてからそれを 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 の UIViewType
に UILabel
を指定していることがわかると思います。makeUIView
で UILabel
のインスタンスを生成して返し、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 であることを意識する必要がないというのはとてもよい設計だと思います。
この画面は以下のように動作します。
各メソッドとライフサイクル
基本的な使い方がわかったところで、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
updateUIView
は makeUIView
が呼ばれた直後に一度、その後は 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
が加わったこと以外は同じです。
以下、いくつかの操作に対してどのメソッドが呼ばれるかをデバッグ出力から見ていきます。
表示時
まず、初回の表示時のデバッグ出力を見てみます。
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
が呼ばれ、その後裏の UILabel
も deinit
されています。このあと再び Toggle
をタップして CounterLabelRepresentable
を表示すると、初回表示と同じように CounterLabelRepresentable
と UILabel
が生成されます。裏の 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)
}
}
動作は以下のようになります。
ここで、背景色の有無を切り替えたときのデバッグ出力は以下です。
Parent body
CounterLabelRepresentable init(count:)
@State
が更新されたことによって親 View の body
の再評価がされ、それに伴い CounterLabelRepresentable
が init
で初期化されています。ただし count
を変化させたときと違って updateUIView
が走らないことがわかります。この例では CounterLabelRepresentable
が依存するデータが変化していないので再描画の必要はなく、パフォーマンスの観点からは望ましい振る舞いが実現されていると言えます。SwiftUI は各 View がどのデータに依存しているのかを把握し、データの更新が発生するとそのデータを使っている View のみを再描画するということだと思います。
Environment
ここまでは UIViewRepresentable の表示に必要なデータを引数として渡す場合について見てきました。実は、通常の SwiftUI の View と同じように UIViewRepresentable は Environment からも値を受け取ることができます。
カウンターの例を少し書き変えて、親 View から CounterLabelRepresentable
に disabled
modifier をつけて、値を切り替えられるようにします。 CounterLabelRpresentable
の側では、引数の updateUIView
の引数の context
の environment.isEnabled
からその値が取得できるので、それに応じてテキストの色を変化させます。 disabled
が true
の時はテキストの色を薄くすることにしましょう。
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
を切り替えたときのデバッグ出力は以下で、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 のアクションに対応して何かするものというくらい意味で、UIScrollViewDelegate
や UISearchBarDelegate
のような 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 と同じタイミングで生成・消滅することがわかります。
まとめ
- UIViewRepresentable は SwiftUI の中で一部のみ UIView を使うためのしくみ
-
makeUIView
は初期化時に一度だけ、updateUIView
は初期化時とその後の状態更新時に呼ばれる。データを表示に反映するのはupdateUIView
で行う - 実体の UIView のインスタンスは状態更新のたびに作り直されるのではなく、非表示になるまで使い回される
- UIViewRepresentable からなんらかのアクションを起こしたい場合は Coordinator を使う
参考
- https://developer.apple.com/documentation/swiftui/uiviewrepresentable
- https://developer.apple.com/documentation/swiftui/uiviewrepresentablecontext
- https://www.hackingwithswift.com/books/ios-swiftui/using-coordinators-to-manage-swiftui-view-controllers
- https://swiftontap.com/UIViewRepresentable
- https://www.swiftbysundell.com/articles/swiftui-and-uikit-interoperability-part-1/