[iOS 13] SwiftUI + Combine + APIKitでインクリメンタルサーチ
CX事業本部の太田です。
前回の記事で、RxSwiftでのインクリメンタルサーチをやりましたが、今回はiOS 13
で追加されたSwiftUI
とCombine
を使って
同様のインクリメンタルサーチを作ってみました。
環境
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
とするだけで使えます。
cocoapods
やCarthage
より、インストールやバージョン管理がしやすくなったかと思います。
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 } }
作成したSearchBar
とWikiSearchModel
を使って、検索画面を作成します。
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
で用意されていないコンポーネントがあったりしますが、SwiftUI
とCombine
の連携がしやすくなっているように感じました。
まだまだ、使い方がわからない部分や不慣れな部分が多く、これからも触ってより良い方法を見つけられたらと思います。
今回は、Mojave
だったのでSwiftUI
のプレビュー機能には触れてませんが、Appleのチュートリアルを見るといい感じなのでそちらも触っていきたいと思います。
SwiftUI
とCombine
はiOS 13
からなので、本格的に使うのはまだ先になると思いますが、それまでに、SwiftUI
とCombine
も使いやすくなると思うので楽しみです。
最後までご覧いただき、ありがとうございました。