8
7

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.

GoodpatchAdvent Calendar 2021

Day 14

Delegateを伴うUIKit実装のカスタムビューをSwiftUI上で使う

Last updated at Posted at 2021-12-14

はじめに

この記事は 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 に必要なプロパティを定義する

  1. このUI上で外部から動的に与えられ、表示に連動するデータを @Binding として定義します
  2. delegate 経由でのイベントハンドリングを SwiftUI 側にも提供するために、外部から指定されたコールバックを保持できるようにします

いずれも、イニシャライザや View Modifier 経由で指定し、外部から直接触ることはないため、private として十分です。

struct BrickView: UIViewRepresentable {

    @Binding private var items: [String]

    private var onSelectItemCallback: ((String) -> Void)?

    ...

3. Coordinator を定義する

  1. Coordinator を実装
    1. Coodinatorには、今回実装しているUIViewRepresentable準拠のViewインスタンスをもとに初期化する設計とします
    2. @Bindingとして定義した、動的に変化するデータもプロパティとして定義します
  2. makeCoordinator を実装
    1. 上記にあわせて、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 の初期化/更新処理を実装

  1. makeUIView(context:) を実装
    • UI生成の初回一度のみ呼び出される処理です
    • 生成したビューの dataSource delefate に、 context から得られる coordinator を指定します
  2. 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

参考

SwiftUI Tutorials / Interfacing with UIKit

8
7
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
8
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?