[iOS 13] SwiftUI + Combine + APIKitでインクリメンタルサーチ

CX事業本部の太田です。

前回の記事で、RxSwiftでのインクリメンタルサーチをやりましたが、今回はiOS 13で追加されたSwiftUICombineを使って 同様のインクリメンタルサーチを作ってみました。

環境

macOS Mojave 10.14.6
Xcode 11.0 beta 6
Swift 5
APIKit 5.0.0

APIKitのインポート

Xcode 11からSwift Package Managerが使えるようになったので、これを使ってAPIKitをインポートしてみます。

左側のProject Navigatorのプロジェクト名から、Swift Packageというタブを開きます。

左下に+ボタンがありますので、これを押下すると追加するライブラリの画面が表示されます。

表示された画面で追加したライブラリのgitリポジトリを指定します。
今回は、APIKitを追加したいので、https://github.com/ishkawa/APIKitを入力すると、ライブラリのバージョンを指定する画面が開きます。

なにも変えずにNextボタンを押下します。

Finishボタンを押下すると、ライブラリの追加が完了です。
あとは使いたいところで、Import APIKitとするだけで使えます。

cocoapodsCarthageより、インストールやバージョン管理がしやすくなったかと思います。

APIKit.DataParser

前回の記事を参考にしてください。

Entity

前回の記事を参考にしてください。

APIKit.Request

こちらも、ほぼ前回の記事と同様なのですが、 APIKit.Requestに後述する、APIPublisherを提供できるようにします。

extension APIKit.Request where Self.Response: Decodable {

    var publisher: APIPublisher<Self> {
        return APIPublisher(request: self)
    }

    .... // 後は前回と同じです
}

PublisherとSubscription

Publisherを継承したクラスを作り、通信の結果をストリームに流せるようにします。
Publisherは、RxSwiftでいうとObservableと同じような感じです。

struct APIPublisher<R: APIKit.Request>: Publisher where R.Response: Decodable {
    // 処理成功時の型
    typealias Output = R.Response
    // 処理失敗時の型
    typealias Failure = Error

    let request: R

    func receive<S>(subscriber: S) where S : Subscriber, Failure == S.Failure, Output == S.Input {
        // 通信処理
        let task = Session
            .shared
            .send(request) { result in
                switch result {
                case .success(let res):
                    // 処理成功
                    let _ = subscriber.receive(res)
                    subscriber.receive(completion: .finished)
                case .failure(let error):
                    // 処理失敗
                    subscriber.receive(completion: .failure(error))
                }
        }

        // 登録完了を通知
        // 通信をキャンセルできるようにtaskを渡す
        let subscription = APISubscription(combineIdentifier: CombineIdentifier(), task: task)
        subscriber.receive(subscription: subscription)
    }
}

次に、Subscriptionを継承したクラスを作り、通信をキャンセルできるようにします。
Subscriptionは、RxSwiftでいうとDisposableと同じような感じです。

上記の、APIPublisher.receiveの最後で生成して渡すことで、任意のタイミングでキャンセルさせることが可能になります。

struct APISubscription: Subscription {

    let combineIdentifier: CombineIdentifier
    // APIPublisherから通信に使ったインスタンスをもらって保持する
    let task: APIKit.SessionTask?

    func request(_ demand: Subscribers.Demand) {}

    func cancel() {
        // 通信をキャンセルする
        self.task?.cancel()
    }
}

これで、通信をするための準備が整いましたので、UIを作っていきます。

Wikiの検索画面

Wikiの検索画面を作ります。
ただ、UISearcBarはまだSwiftUIには用意されていませんので、使えるようにUIViewRepresentableを継承したクラスを作成します。

struct SearchBar: UIViewRepresentable {

    private var delegate: SearchBarControl!

    var title: String?

    init(_ title: String?, text: Binding<String>) {
        // SwiftUIのTextFieldを参考にinit処理を行う
        self.title = title
        self.delegate = SearchBarControl(text)
    }

    func makeUIView(context: Context) -> UISearchBar  {
        // 表示したいviewを生成して返す
        let view = UISearchBar(frame: .zero)
        view.placeholder = self.title
        view.delegate = self.delegate
        return view
    }

    func updateUIView(_ uiView: UISearchBar, context: Context) {
    }

    private class SearchBarControl: NSObject, UISearchBarDelegate {

        var text: Binding<String>

        init(_ text: Binding<String>) {
            self.text = text
        }

        func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
            // 文字が入力されたら値を更新する
            self.text.wrappedValue = searchBar.text ?? ""
        }
    }
}

実際に検索を行うモデルを定義します。
ObservableObjectを継承させることで、@ObjectBindingを宣言したプロパティに変更通知を行うようになります。

final class WikiSearchModel: ObservableObject {
    // 検索結果の保持
    // @PublishedをつけることでPublisherにすることができる
    @Published var itmes: [Search] = []
    // 検索文字
    var searchText = "" {
        didSet {
            // 変更されたら検索APIをコールする
            self.searchWiki()
        }
    }
    // APIキャンセル用インスタンス
    var requestCancellable: Cancellable?

    // 検索の実行
    func searchWiki() {
        // APIキャンセル用インスタンスを保持
        self.requestCancellable = WikiRequest(query: self.searchText)
            .publisher
            .sink(receiveCompletion: { (completion) in
                switch completion {
                case .finished:
                    // 成功時に行いたい処理
                    break
                case .failure(let error):
                    // 失敗時に行いたい処理
                    break
                }

            }) { (entity) in
                // 成功時のレスポンスはここにくる
                self.itmes = entity.query?.search ?? []
            }
    }

    func cancel() {
        // キャンセル処理
        self.requestCancellable?.cancel()
        self.requestCancellable = nil
    }
}

作成したSearchBarWikiSearchModelを使って、検索画面を作成します。

struct WikiSearchList: View {
    // @ObservedObjectをつけることで、変更を検知することが可能になります。
    @ObservedObject var viewModel = WikiSearchModel()

    var body: some View {
        NavigationView {
            VStack {
                // SearchBarの入力されるたびに、viewModel.searchTextが更新される
                SearchBar("検索文字を入力", text: $viewModel.searchText)
                // viewModel.itmesを元にリストを作成する
                // idは一意となるパラメーター名を指定する 今回はSearch.pageidが一意なのでそれを利用
                // viewModel.itmesが更新されると、再描画されます
                List (viewModel.itmes, id: \.pageid) { item in
                    NavigationLink(destination: WikiDetail(item: item)) {
                        // Rowとして表示したいView 今回はタイトルのみを表示
                        Text(item.title)
                    }
                }
            }.onDisappear {
                // 破棄される際に通信をキャンセルする
                self.viewModel.cancel()
            }
            // ナビゲーションのタイトルを設定
            .navigationBarTitle(Text("検索"), displayMode: .inline)
        }
    }
}

Wikiの詳細画面

Wikiの詳細画面を作ります。
UISearchBarと同様に、WKWebViewもまだSwiftUIに用意されていないので、使えるようにする必要があります。

struct WebView : UIViewRepresentable {

    var pageId: Int

    var url: String {
        return String(format: "https://ja.wikipedia.org/w/index.php?curid=%d", self.pageId)
    }

    func makeUIView(context: Context) -> WKWebView  {
        return WKWebView(frame: .zero)
    }
    func updateUIView(_ uiView: WKWebView, context: Context) {
        let req = URLRequest(url: URL(string: self.url)!)
        uiView.load(req)
    }
}

あとは、WebViewを使って詳細画面を作ります

struct WikiDetail: View {

    var item: Search

    var body: some View {
        WebView(pageId: item.pageid)
        .navigationBarTitle(Text(item.title), displayMode: .inline)

    }
}

SceneDelegateの編集

SceneDelegateで最初に表示するviewを生成しているので、そこをWikiSearchListに変更します。

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    ....

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
        // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
        // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).

        // Create the SwiftUI view that provides the window contents.
        let contentView = WikiSearchList() // ここだけ変更

        // Use a UIHostingController as window root view controller.
        if let windowScene = scene as? UIWindowScene {
            let window = UIWindow(windowScene: windowScene)
            window.rootViewController = UIHostingController(rootView: contentView)
            self.window = window
            window.makeKeyAndVisible()
        }
    }

    ....

}

完成

以上で、インクリメンタルサーチの完成です。

最後に

SwiftUIで用意されていないコンポーネントがあったりしますが、SwiftUICombineの連携がしやすくなっているように感じました。
まだまだ、使い方がわからない部分や不慣れな部分が多く、これからも触ってより良い方法を見つけられたらと思います。

今回は、MojaveだったのでSwiftUIのプレビュー機能には触れてませんが、Appleのチュートリアルを見るといい感じなのでそちらも触っていきたいと思います。

SwiftUICombineiOS 13からなので、本格的に使うのはまだ先になると思いますが、それまでに、SwiftUICombineも使いやすくなると思うので楽しみです。

最後までご覧いただき、ありがとうございました。