はじめに
この記事は Goodpatch Advent Calendar 2021 14日目の記事です。
SwiftUI が登場してはや3年が過ぎました。年々 Apple からは WWDC の場を通して大幅な機能拡充が発表され、そろそろプロダクト開発への利用にも応えうる水準に達してきたように感じています。
とはいえ、少し凝った独自UIの開発をしようとすると、宣言的な記述で実装する SwiftUI の枠組みだけでは実装が難しかったり、できたとしても無用に複雑化してしまうケースに陥るため、結局 UIKit に頼らざるを得なかった、という場面は続きそうです。
今回は、そんな UIKit ベースの独自UI実装を、SwiftUI に組み込む方法をまとめてみました。表示データの提供やイベントハンドリングは、UIKit ベースのビュー開発では一般的な DataSource/Delegate パターンで実装した事例をもとにしています。
今回のサンプル
一昨年の Advent Calendar で、ちょうど UICollectionViewLayout をカスタムした、レンガ状の CollectionView を実装したので、これをSwiftUIで表示できるようにします。
// MARK: - BrickCollectionView Protocols
protocol BrickViewDataSource: AnyObject {
func numberOfItems(_ brickView: BrickCollectionView) -> Int
func brickView(_ brickView: BrickCollectionView, itemAt index: Int) -> String
}
protocol BrickViewDelegate: AnyObject {
func brickViewDidTapItem(_ brickView: BrickCollectionView, item: String)
}
// MARK: - BrickCollectionView
final class BrickCollectionView: UIView {
private var collectionView: UICollectionView!
weak var dataSource: BrickViewDataSource?
weak var delegate: BrickViewDelegate?
func reloadData() {
collectionView.reloadData()
}
}
// MARK: - UICollectionViewDataSource
extension BrickCollectionView: UICollectionViewDataSource {
func numberOfSections(in collectionView: UICollectionView) -> Int {
return 1
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return dataSource?.numberOfItems(self) ?? 0
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: BrickCollectionView.Cell.self), for: indexPath) as! Cell
cell.contentView.backgroundColor = .white
cell.label.text = item(for: indexPath)
return cell
}
}
// MARK: - UICollectionViewDelegate
extension BrickCollectionView: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
delegate?.brickViewDidTapItem(self, item: item(for: indexPath))
}
}
SwiftUI で活用するための実装STEP
1. UIViewRepresentable
に準拠した SwiftUI wrapper を定義する
まず初めに、対象の UIKit ビューの SwiftUI ラッパー表現である、 UIViewRepresentable
を定義します。このプロトコルに準拠するには、Coordinator の定義と、以下のfunctionを実装する必要があります。
struct BrickView: UIViewRepresentable {
// Coordinator の生成処理
func makeCoordinator() -> Coordinator {
Coordinator()
}
// ビュー生成の初回のみ呼びだされる処理
func makeUIView(context: Context) -> BrickCollectionView {
let ret = BrickCollectionView()
// TODO: 実装
return ret
}
// 表示値の変更に伴ってビューの表示を更新する際にシステムから呼びだされる処理
func updateUIView(_: BrickCollectionView, context: Context) {
// TODO: 実装
}
// UIKit ビューと SwiftUI 間の変化を中継する Coordinator
class Coordinator: NSObject {
// TODO: 実装
}
}
2. SwiftUI wrapper に必要なプロパティを定義する
- このUI上で外部から動的に与えられ、表示に連動するデータを
@Binding
として定義します - delegate 経由でのイベントハンドリングを SwiftUI 側にも提供するために、外部から指定されたコールバックを保持できるようにします
いずれも、イニシャライザや View Modifier 経由で指定し、外部から直接触ることはないため、private
として十分です。
struct BrickView: UIViewRepresentable {
@Binding private var items: [String]
private var onSelectItemCallback: ((String) -> Void)?
...
3. Coordinator を定義する
- Coordinator を実装
- Coodinatorには、今回実装しているUIViewRepresentable準拠のViewインスタンスをもとに初期化する設計とします
-
@Binding
として定義した、動的に変化するデータもプロパティとして定義します
-
makeCoordinator
を実装- 上記にあわせて、Coordinatorの生成時に自身をパラメタに渡すようにします
struct BrickView: UIViewRepresentable {
...
// 2. `makeCoordinator` を実装
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
...
// 1. Coordinator を実装
class Coordinator: NSObject, BrickViewDataSource, BrickViewDelegate {
var parent: BrickView
var items: [String] = []
var onSelectItemCallback: ((String) -> Void)?
init(_ brickView: BrickView) {
self.parent = brickView
}
}
4. Coordinator と DataSource/Delegate を定義する
Coordinator を、UIKit 側で定義した DataSource や Delegate に準拠させます。
class Coordinator: NSObject, BrickViewDataSource, BrickViewDelegate {
...
func numberOfItems(_ brickView: BrickCollectionView) -> Int {
return items.count
}
func brickView(_ brickView: BrickCollectionView, itemAt index: Int) -> String {
guard index < items.count else { return "" }
return items[index]
}
func brickViewDidTapItem(_ brickView: BrickCollectionView, item: String) {
onSelectItemCallback?(item)
}
}
5. カスタム View の初期化/更新処理を実装
-
makeUIView(context:)
を実装- UI生成の初回一度のみ呼び出される処理です
- 生成したビューの
dataSource
delefate
に、context
から得られるcoordinator
を指定します
-
updateUIView(_:context:)
を実装- UIの更新時にシステムから都度呼び出される処理です
- そのため、データに更新があるごとに、Coordinator のプロパティにも随時反映します
struct BrickView: UIViewRepresentable {
...
func makeUIView(context: Context) -> BrickCollectionView {
let ret = BrickCollectionView()
ret.delegate = context.coordinator
ret.dataSource = context.coordinator
return ret
}
func updateUIView(_ brickView: BrickCollectionView, context: Context) {
context.coordinator.items = items
context.coordinator.onSelectItemCallback = onSelectItemCallback
brickView.reloadData()
}
...
6. Delegate の処理を SwiftUI 側に露出する View Modifier を実装する
- コールバックを指定するView Modifier を定義し、delegate 経由で伝播されるイベントハンドリングを、SwiftUI 側から指定できるようにします
struct BrickView: UIViewRepresentable {
...
/// アイテムが選択された際のコールバックを指定します
func onSelectItem(callback: @escaping (String) -> Void) -> Self {
var ret = self
ret.onSelectItemCallback = callback
return ret
}
...
7. SwiftUI側で利用する
こうした手順により生成したビューは、以下のように UIKit のラッパーであることを意識せずに利用することができます。
struct ContentView: View {
@State var items: [String] = []
@State var newItem: String = ""
var body: some View {
BrickView(items: $items)
.onSelectItem { selected in
print("selected: \(selected)")
}
}
}
おわりに
Appleの提供するSwiftUIチュートリアルにも、この一部の実装ステップは解説されていますが、独自のカスタムビュー実装については試行錯誤した面があったので、手順化することで類似実装時の迷いをなくせるだろうと記事にしてみました。
実際に、単純な App 例として実装してみましたので、ご参考ください。
p0dee / UIKit-SwiftUInize-Sample