17
9

More than 3 years have passed since last update.

SwiftUI(TextField)のみでインクリメンタルサーチを実現する

Last updated at Posted at 2020-09-03

はじめに

SwiftUIのみを利用してインクリメンタルサーチの機能を実装する方法です。

そもそもインクリメンタルサーチとは何かという点については以下をご参照ください。

Wikipedia
インクリメンタルサーチ(英語: incremental search)は、アプリケーションにおける検索方法のひとつ。検索したい単語をすべて入力した上で検索するのではなく、入力のたびごとに即座に候補を表示させる。逐語検索、逐次検索とも。

イメージとしては、検索機能を提供する画面などで、ユーザーが明示的にサブミットアクションを実行しなくても、入力文字列に変化があるたびに自動でデータが更新されるような挙動を実現する方法です。
ブラウザの検索窓に文字列を入力すると、サジェストや履歴が表示される挙動がこれにあたります。

従来のUIKit, RxSwift, etcを用いた方法

SwiftUIを使わない場合、UIKitのUISearchController, UISearchBarDelegate, UITextFieldDelegate, UITextViewDelegateを用いたり、
もしくは RxSwift などのリアクティブプログラミングのライブラリを用いて、インクリメンタルサーチの実装することができました。

これらは値の変更ハンドラを提供してくれるので、そこで入力値の変更をキャッチしてデータソースを更新することができます。

※ 本記事では詳細な実装方法は紹介しませんが、「UIKit インクリメンタルサーチ」などで検索するとわかりやすい記事が沢山出てくるので、そちらをご参照ください。

SwiftUIを用いた方法

それでは、SwiftUIではどうでしょうか。
SwiftUIのテキスト入力コンポーネントとしては TextField があるので、これを用いてインクリメンタルサーチを実装することになります。

1.入力値の変更を明示的にハンドリングしなくていい場合

以下のサンプルコードは TextField の入力値に応じて、リストに表示されるアイテムを自動的にフィルタリングするUIを提供します。

struct ContentView: View {
    @ObservedObject private var viewModel = ViewModel()

    var body: some View {
        VStack {
            TextField("input here", text: $viewModel.text)
                .padding()
            List {
                ForEach(viewModel.items, id: \.self) { item in
                    Text(item)
                }
            }
        }
    }
}

class ViewModel: ObservableObject {
    @Published var text: String = ""
    private let allItems: [String] = (0..<100).map { "\($0)" }

    var items: [String] {
        text.isEmpty ? allItems : allItems.filter { $0.contains(text) }
    }
}

Simulator Screen Shot - iPad (7th generation) - 2020-08-28 at 10.23.56.png (126.4 kB)

これだけの実装でインクリメンタルサーチを実装することができました:tada:

しかし、このようなシンプルな挙動を実現するだけであれば今の状態で問題ないのですが、より高度な挙動を実現したいと思った場合、実はこのままでは対応することができません。

例えば、「入力された文字列を元にAPIを叩いて返却されたものを画面に表示したい」といったケースがあるとします。
アプリの挙動としては下記のような処理フローを実行させたいですが、現状1と3の間に処理を挟むことができません。

  1. 入力値が変更される
  2. 入力値を元にAPIを叩く <= できない:cry:
  3. UIが更新される

この時点でSwiftUIのみで実現することを諦め、 Representable を用いてUIKitをブリッジすることもできるのですが、出来ればSwiftUIのみで実装を完結させたくなります。

この問題は、 onReceiveでPublisherのストリームを監視する ことで解決できます。

2.入力値の変更を明示的にハンドリングしたい場合

コードは下記のような形になります。

struct ContentView: View {
    @ObservedObject private var viewModel = ViewModel()

    var body: some View {
        VStack {
            TextField("input here", text: $viewModel.text)
                .padding()
            List {
                ForEach(viewModel.items, id: \.self) { item in
                    Text(item)
                }
            }
+       }.onReceive(
+           viewModel.$text
+       ) { (text) in
+           self.viewModel.filter(by: text)
+       }
    }
}

class ViewModel: ObservableObject {
    @Published var text: String = ""
    private let allItems: [String] = (0..<100).map { "\($0)" }

-   var items: [String] {
-       text.isEmpty ? allItems : allItems.filter { $0.contains(text) }
-   }
+   @Published var items: [String] = []
+
+   func filter(by text: String) {
+       items = text.isEmpty ? self.allItems : self.allItems.filter { $0.contains(text) }
+   }
}

先ほどのサンプルコードにonReceiveModifierを追加し、ObservableObjectのPubishedプロパティを監視するように変更しています。
このようにすることで、Publisherのストリームをハンドリングすることができるようになるため、任意のオペレーターや処理を挟むことができるようになります。

挙動は以下のようになり、先ほどのサンプルから挙動に変化がないことがわかると思います。

Simulator Screen Shot - iPad (7th generation) - 2020-08-28 at 10.23.56.png (126.4 kB)

API, Database, etcとの連携

上記の例で、変更をフックして処理を挟み込むことができるようになったので、あとはこのタイミングでAPIやDatabaseへのアクセスなどを行えばやりたいことを実現できます。

struct ContentView: View {
    @ObservedObject private var viewModel = ViewModel()

    var body: some View {
        VStack {
            TextField("input here", text: $viewModel.text)
                .padding()
            List {
                ForEach(viewModel.items, id: \.self) { item in
                    Text(item)
                }
            }
        }.onReceive(
            viewModel.$text
+               .debounce(for: 1.0, scheduler: DispatchQueue.main)
        ) { (text) in
            self.viewModel.filter(by: text)
        }
    }
}

class ViewModel: ObservableObject {
    @Published var text: String = ""
    private let allItems: [String] = (0..<100).map { "\($0)" }

    @Published var items: [String] = []
+   private let itemRepository = ItemRepository()
+   private var cancellable: AnyCancellable?

+   deinit {
+       cancellable?.cancel()
+   }

    func filter(by text: String) {
+       cancellable?.cancel()
+       cancellable = itemRepository.getItems(filterBy: text)
+           .assign(to: \.items, on: self)
    }
}

+struct ItemRepository {
+   private let allItems = (0..<100).map { "\($0)" }
+
+   // サンプルなので、実際には通信せずにスタブデータを返却
+   func getItems(filterBy text: String) -> AnyPublisher<[String], Never> {
+       Future { promise in
+           let items = text.isEmpty ? self.allItems : self.allItems.filter { $0.contains(text) }
+           promise(.success(items))
+       }.eraseToAnyPublisher()
+   }
+}

onReceiveの中にCombineのオペレーター(debounce)を追加し、ストリームの流れを制御するようにしました。
このように、Publisherを挟んだことのメリットの1つとして、イベントを柔軟に制御できるオペレーターが利用できるようになる点があります。
今回の例だと、 debounce オペレーターを追加することで、不要な連続リクエストを防ぐことが可能になっています。

また、ストリームのアウトプットがObservableObjectに引き渡され、Viewにデータを反映させる前にAPIを叩くことができているのもわかると思います。

これでやりたいことを実装することができました。
挙動はこちらのようになります。
Simulator Screen Shot - iPad (7th generation) - 2020-08-28 at 10.23.56.png (126.4 kB)

おわりに

このようにPublishedプロパティの変化をonReceiveで監視することで、SwiftUIのみでインクリメンタルサーチを実現することができるようになります。
実装もそこまで複雑でなくシンプルなコードで実現できていることがわかると思います。

おまけ

CurrentValueSubjectを使うと、ObservableObjectを使わずViewだけで完結させることもできます。(わずかこれだけ!)

struct ContentView: View {
    @State private var items: [String] = []

    private let textPublisher = CurrentValueSubject<String, Never>("")
    private var textBinding: Binding<String> {
        .init(
            get: { self.textPublisher.value },
            set: { self.textPublisher.send($0) }
        )
    }

    private let itemRepostiory = ItemRepository()

    var body: some View {
        VStack {
            TextField("input here", text: textBinding)
                .padding()
            List {
                ForEach(items, id: \.self) { item in
                    Text(item)
                }
            }
        }.onReceive(
            textPublisher
                .debounce(for: 1.0, scheduler: DispatchQueue.main)
                // flatMapを使うと先に実行が始まった処理を待ってしまうという問題はあります (flatMapLatestを自作するとこの問題を解決できるそうです)
                .flatMap(maxPublishers: .max(1), {
                    self.itemRepostiory.getItems(filterBy: $0)
                })
        ) { (items) in
            self.items = items
        }
    }
}

参考

17
9
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
17
9