はじめに
SwiftUIだけでは用意されていないコンポーネントがあったりでUIKit使いたいなーという状況はよくあるかと思います。
そんな時は、SwiftUIで頑張らないで、UIKitで作ってしまえ...と思い、カスタムビューを作成したのですが、カスタムビュー側で保持している値
をSwiftUIのView側で使いたい
という状況に遭遇したので、
今回はその方法を備忘録兼ねて残します。
また、今回は、SwiftUIの方にないSearchBarをUIKitで作り、UISearchBar
のテキストフィールドに入力された値をSwiftUI側で取得するというサンプルで試してみます。
環境
Xcode11 GM SEED
SwiftUI
Mac OS 10.14.6(18G103)
まずXibを使ったカスタムビューを作成しておく
以下のようにカスタムビューをUIView
で定義しておきます。
今まで通りAutoLayoutはしっかり設定させます。
アウトレットでついているのは、UISearchBar
とカスタムビュー自体のContentViewだけ。
そしてXib側ではLabelを追加しています。
また、見やすいようにUIKitで作成したカスタムビューは青色にしておきます。
import UIKit
// Define UIKitSearchView in the usual way to create custom views.
class UIKitSearchView: UIView {
@IBOutlet weak var contentView: UIView!
@IBOutlet weak var searchBar: UISearchBar!
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
commonInit()
}
private func commonInit() {
Bundle.main.loadNibNamed("UIKitSearchView", owner: self, options: nil)
contentView.frame = bounds
addSubview(contentView)
searchBar.placeholder = "UIKitSearchView's searchBar"
searchBar.backgroundImage = UIImage()
}
}
これでカスタムビューの準備は終わりました。
UIViewRepresentableを準拠したSwiftUISearchViewを定義する
SwiftUIで宣言されたViewの中でUIViewを使う場合は、UIViewRepresentable
プロトコルを使用したViewでラップすることが必要です。
このSwiftUISearchView
がSwiftUIで使う為のラップビューとなります。
import SwiftUI
struct SwiftUISearchView: UIViewRepresentable {
func makeUIView(context: Context) -> UIKitSearchView {
print("\(#function)")
return UIKitSearchView()
}
func updateUIView(_ uiView: UIKitSearchView, context: Context) {
print("\(#function)")
}
}
シンプルにUIViewを表示させるだけであれば、このSwiftUISearchView
を宣言すれば終わりですが、このままではUISearchBar
に入力された文字列を取得することができません。
UIViewRepresentableの定義を見てみる
UIKitSearchView側でクロージャを保持するプロパティを定義したり、強引にやればなんとかなりそうですが、スマートじゃないってことで
UIViewRepresentable
のプロトコルの定義を調べてみます。
public protocol UIViewRepresentable : View where Self.Body == Never {
/// The type of `UIView` to be presented.
associatedtype UIViewType : UIView
/// Creates a `UIView` instance to be presented.
func makeUIView(context: Self.Context) -> Self.UIViewType
/// Updates the presented `UIView` (and coordinator) to the latest
/// configuration.
func updateUIView(_ uiView: Self.UIViewType, context: Self.Context)
/// Cleans up the presented `UIView` (and coordinator) in
/// anticipation of their removal.
static func dismantleUIView(_ uiView: Self.UIViewType, coordinator: Self.Coordinator)
/// A type to coordinate with the `UIView`.
associatedtype Coordinator = Void
/// Creates a `Coordinator` instance to coordinate with the
/// `UIView`.
///
/// `Coordinator` can be accessed via `Context`.
func makeCoordinator() -> Self.Coordinator
typealias Context = UIViewRepresentableContext<Self>
}
@available(iOS 13.0, tvOS 13.0, watchOS 6.0, *)
@available(OSX, unavailable)
extension UIViewRepresentable where Self.Coordinator == Void {
/// Creates a `Coordinator` instance to coordinate with the
/// `UIView`.
///
/// `Coordinator` can be accessed via `Context`.
public func makeCoordinator() -> Self.Coordinator
}
@available(iOS 13.0, tvOS 13.0, watchOS 6.0, *)
@available(OSX, unavailable)
extension UIViewRepresentable {
/// Cleans up the presented `UIView` (and coordinator) in
/// anticipation of their removal.
public static func dismantleUIView(_ uiView: Self.UIViewType, coordinator: Self.Coordinator)
/// Declares the content and behavior of this view.
public var body: Never { get }
}
ふむふむ。
なんだかUIViewRepresentableを継承すると、以下の実装が必要となるようです。
makeUIView(context:)
updateUIView(_:context:)
makeCoordinator()
dismantleUIView(_ uiView:coordinator:)
ただ、makeCoordinator()
、dismantleUIView(_ uiView:coordinator:)
はextensionでデフォルト実装がされているので、定義不要みたいですね。
なんかmakeCoordinator()が使えそう
dismantleUIView
は調べたところ、表示されるUIKitビュー(およびコーディネーター)をクリーンアップするのに使うらしい。(よくわかりませんでした。)
どなたか教えていただけると嬉しいです。
ということでmakeCoordinator
がなんか使えそう。
ググってみると
なんでも、このCoordinator
を使用するとデリゲートやデータソース、ユーザーの操作に対するイベントハンドリングのようなUIKitなどで実装していた一般的な処理をSwiftUIでも実装できるらしい。
extension UIViewRepresentable where Self.Coordinator == Void {
/// Creates a `Coordinator` instance to coordinate with the
/// `UIView`.
///
/// `Coordinator` can be accessed via `Context`.
public func makeCoordinator() -> Self.Coordinator
}
UIViewRepresentable
を継承したSelfにあるCoordinator
がなければ、デフォルト実装をするようになっているみたいなので、
つまりは、さきほど作ったSwiftUISearchView
の中にCoordinator
を作ればいいっぽい。
そして、このCoordinator
にUISearchBarDelegate
をつけちゃいます。
Coordinator
を作ったらmakeCoordinator
も追加して、さらにdelegate
も指定させます。
struct SwiftUISearchView: UIViewRepresentable {
func makeCoordinator() -> SwiftUISearchView.Coordinator {
return Coordinator()
}
func makeUIView(context: Context) -> UIKitSearchView {
print("\(#function)")
let view = UIKitSearchView()
view.searchBar.delegate = context.coordinator
return view
}
func updateUIView(_ uiView: UIKitSearchView, context: Context) {
print("\(#function)")
}
}
extension SwiftUISearchView {
class Coordinator: NSObject, UISearchBarDelegate {
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
print("\(#function)")
}
func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
print("\(#function)")
}
}
}
おー、これでCoodinatorで通知を受け取れるようになりました。
UISearchBarのテキストに入力した値を取得する
@EnvironmentObject
を使えば簡単ですが、それだと設計上良くなさそう。
なので、今回は、@State
+ @Binding
で実現させてみます。
おさらい
@State
とは?
SwiftUIだとViewはstructなので、値の更新ができないけれども
@Stateを宣言すると、値を更新することができるようになるバインド用の修飾子。
@Binding
とは?
親Viewの@State attributeがついた変数などの値の更新通知を受け取れるようにするバインド修飾子のようです。(なんか使えそう...)
僕の中でこいつはまだふわっとしています。アドバイスお待ちしております
バインドさせるイメージ
1.親のContentViewでtextを保持
2.子のSwiftUISearchViewに親のContentViewで定義したtextを渡して同期させる
3.UISearchBarの変更通知を受けるのはCoordinatorなので、SwiftUISearchViewからCoordinatorへもtextを渡して同期させる
こんな感じで行けるんじゃないかと思っています。
それでは、残りをやっていきます。
親ビューを作ってtextをバインドさせる
まず親ViewであるContentView作ります。
そこで@StateでUISearchBarからの入力値を保持しておく受け皿を用意しておきます。
struct ContentView: View {
// 親ビューで値が知りたいので、@Stateでtextを定義しておく
@State private var text: String = DEFAULT_TEXT
var body: some View {
VStack(alignment: .center) {
// バインドさせたいのでSwiftUISearchViewの初期化処理で突っ込む
SwiftUISearchView(text: $text)
.frame(height: 100.0)
Spacer()
VStack {
Text("SwiftUI View")
.font(.title)
.padding(.bottom, 20)
Text(text)
}
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.blue).opacity(0.5))
.padding()
Spacer()
}
}
}
SwiftUISearchViewとCoordinatorにもtextを保持するようにプロパティと処理を追加
親ビューに続いて、SwiftUISearchView、Coordinatorにもtextをバインドさせる為に、@Bindingでプロパティを追加し、さらに初期化やデリゲート通知を受けた時の処理を追加しておきます。
struct SwiftUISearchView: UIViewRepresentable {
@Binding var text: String
func makeCoordinator() -> SwiftUISearchView.Coordinator {
return Coordinator(text: $text)
}
func makeUIView(context: Context) -> UIKitSearchView {
print("\(#function)")
let view = UIKitSearchView()
view.searchBar.delegate = context.coordinator
return view
}
func updateUIView(_ uiView: UIKitSearchView, context: Context) {
print("\(#function)")
}
}
extension SwiftUISearchView {
class Coordinator: NSObject, UISearchBarDelegate {
@Binding var text: String
init(text: Binding<String>) {
_text = text
}
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
print("\(#function)")
text = searchText.isEmpty ? DEFAULT_TEXT: searchText
}
func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
print("\(#function)")
text = DEFAULT_TEXT
searchBar.text = nil
}
}
}
ここまでで一旦完成しました。
あとは実行して確認します。
結果
実行してみると無事値がSwiftUI側の親ビュー側に同期されていました。
イメージ通りです。
これは@Binding
便利です。
github
まとめ
今回は、UIKitのカスタムビュー側の値
をSwiftUIのView側で取得する
という方法をやってみましたが、なんとかイメージ通りに機能したので、すっきりしました。
ただ、これが正しいのかが正直良くわかっていませんので
アドバイスやご指摘などあれば、ぜひぜひコメントお願いいたします。
SwiftUI関連は、これから色々最適解が出てくると思いますので、それまではトライアンドエラーでやってみたいと思います。
それでは、最後までお読みいただきありがとうございました。