LoginSignup
86
52

More than 3 years have passed since last update.

【iOS】iOS13のUISearchBarの新機能を試してみる

Posted at

iOS13では
UISearchBarに関連する下記のクラスが追加されました。

UISearchTextField
https://developer.apple.com/documentation/uikit/uisearchtextfield

UISearchToken
https://developer.apple.com/documentation/uikit/uisearchtoken

UISearchTextFieldDelegate
https://developer.apple.com/documentation/uikit/uisearchtextfielddelegate

色々調べてみたのですが
あまり情報が出てきませんでした。

WWDC2019のセッションでもサンプルコードを出すと言っていたのですが
現在まだ見つけることができていません。
もし正式な情報の場所などご存知の方いらっしゃれば教えてください🙇🏻‍♂️

WWDC2019のセッション動画
https://developer.apple.com/videos/play/wwdc2019/224/?time=1276

そこで
今回は実際にコードを動かしながら
どういう風に活用できるのかを検討した結果を記載したいと思います。

各クラスの概要

UISearchTextField

こちらは元々非公開のプロパティでしたが
iOS13より公開され
UISearchBarのテキストフィールドのカスタマイズが
より簡単にできるようになりました。

下記のようにsearchTextFieldからアクセスできます。


var searchBar = UISearchBar()
searchBar.searchTextField.backgroundColor = .systemOrange
searchBar.searchTextField.textColor = .systemPurple
searchBar.searchTextField.font = UIFont(name: "American Typewriter", size: 18)

このように設定すると下記の様にテキストフィールドの背景色とテキストの色を変更できます。

Simulator Screen Shot - iPhone 11 Pro Max - 2019-11-23 at 12.05.02.png

UISearchToken

こちらは検索キーワードを一個のタグのように
取り扱うことができます。

例えば下記のようにsearchTextFieldプロパティへトークンを追加すると
下記の様に表示されます。


let phoneToken = UISearchToken(icon: UIImage(systemName: "phone"), text: "電話")
let messageToken = UISearchToken(icon: UIImage(systemName: "message"), text: "メッセージ")
searchBar.searchTextField.insertToken(phoneToken, at: 0)
searchBar.searchTextField.insertToken(messageToken, at: 0)
searchBar.searchTextField.tokenBackgroundColor = .systemBlue

Simulator Screen Shot - iPhone 11 Pro Max - 2019-11-23 at 11.41.42.png

※ トークンの色はsearchTextFieldのプロパティに設定しているので
個々のトークンで色を決めることはできないようです。
(あったら嬉しいかなとちょっと思ったりしました。)

UISearchTextFieldDelegate

searchTextField(_:itemProviderForCopying:)というメソッドを一つ持つProtocolです。

引数から見るとコピー&ペーストで使用するように思われます。
(WWDCでもコピー&ペーストと言っていました)

こちら全然情報見つからなかったのですが
検した結果を後ほど記載させていただきたいと思います。

実装

もう少し詳しく動作を確認するために
UISearchBarを使って
UITableViewに表示されるデータの絞り込みをします。

※ 動作の確認をするもので内容は適当ですので予めご了承ください。

事前準備

まず
下記のようなPlanというenumを用意します。
3つのcaseがあって
それぞれにitemsが存在します。

UISearchBarに入力されたキーワードの最初の2文字が一致したら
インスタンスを生成するようにinitを用意します。


enum Plan: String, CaseIterable {
    case business
    case travel
    case shopping

    init?(_ text: String) {
        if text.starts(with: "bu") {
            self = .business
        } else if text.starts(with: "tr") {
            self = .travel
        } else if text.starts(with: "sh") {
            self = .shopping
        } else {
            return nil
        }
    }

    var items: [String] {
        switch self {
        case .business:
            return ["会議", "商談", "プレゼン", "勉強会"]
        case .travel:
            return ["宿泊", "日帰り"]
        case .shopping:
            return ["スーパー", "デパート"]
        }
    }

    var iconName: String {
        switch self {
        case .business:
            return "bag"
        case .travel:
            return "airplane"
        case .shopping:
            return "cart"
        }
    }
}

次に
UISearchBarの設定です。


class ViewController: UIViewController {
    @IBOutlet weak private var tableView: UITableView!

    private var searchController = UISearchController()

    private var searchBar: UISearchBar {
        searchController.searchBar
    }

   override func viewDidLoad() {
        super.viewDidLoad()
        setupSearchBar()
    }

    private func setupSearchBar() {
        searchController.searchResultsUpdater = self
        searchController.searchBar.placeholder = "検索したいキーワード"

        // UISearchTextField
        searchBar.searchTextField.backgroundColor = .systemOrange
        searchBar.searchTextField.textColor = .systemPurple
        searchBar.searchTextField.font = UIFont(name: "American Typewriter", size: 18)
        searchBar.searchTextField.tokenBackgroundColor = .systemBlue
    }
}

ここは上記で見たように表示方法などの設定を行っています。

全体は最後に記載させていただきますので
ここからは要点だけ見ていきます。

トークンの作成

最初にトークンを作成する実装をしていきます。
下記のメソッドはUISearchBarに入力された時に
トークンを作成するメソッドです。


extension ViewController {

    private func setToken(from plan: Plan) {
        // 1
        let planToken = UISearchToken(icon: UIImage(systemName: plan.iconName), text: plan.rawValue)
        // 2
        planToken.representedObject = plan
        // 3
        let field = searchBar.searchTextField
        field.replaceTextualPortion(of: field.textualRange, with: planToken, at: field.tokens.count)
    }
}

まず、1でUISearchTokenのインスタンスを生成しています。

2ではUISearchTokenが公開しているrepresentedObjectプロパティに
それぞれのトークンを識別するための値を設定できます。

取得する際には下記のようにtokensというプロパティから探します。


private func extractSearchPlans() -> [Plan] {
    searchBar.searchTextField.tokens.compactMap { $0.representedObject as? Plan }
}

3で入力した文字列をトークンに置き換えています。

replaceTextualPortionですが
ソースコメントに下記のような説明が記載されています。


Removes any text contained in the specified range, 
inserts the provided token at the specified index, 
and selects the newly-inserted token. 
Does not replace any tokens within the provided range. 
If the range intersects the marked text range, the marked text is committed.

This method is essentially a convenience wrapper 
around the more fundamental `text`, `tokens`, and `selectedTextRange` properties, 
providing the selection behavior the user will expect.

@note
Because this method does not remove any tokens in the provided range, 
the caller can pass the fields selectedTextRange 
to convert the selected portion of the text 
into a token without first having to trim the range.

メソッドの引数で指定しているtextualRangeには
下記のようなソースコメントが記載されています。


The range that corresponds to the fields text, exclusive of any tokens.

@see -[<UITextInput> positionWithinRange:atCharacterOffset:]

トークン部分を除いたテキストの範囲が設定されているようです。

動作

このような設定をすると
下記のような動きが実現できました。

UISearchBar720.gif

コピー&ペースト

ここからはコピー&ペーストの実装をしていきます。

まずsearchTextFieldに下記の設定を追加します。


searchBar.searchTextField.allowsCopyingTokens = true

どういう実装が必要なのかがわからなかったので
allowsCopyingTokensのソースコードを見てみます。

Whether the user can copy tokens to the pasteboard or drag them out of the text field.
To support copying tokens, 
this property must be true and the delegate must provide an item provider 
for the tokens to be copied. 
UISearchTextField always enables the Copy command 
if any plain text is selected, 
even if the selection also includes tokens and this property is false. 
Defaults to true.

ここからわかることとして

the user can copy tokens to the pasteboard

UIPasteboardを使用する

ということと

this property must be true 
and the delegate must provide an item provider 
for the tokens to be copied

とあるのでdelegateの実装が必要になるようです。

UIPasteboardの説明は割愛させていただきます。
↓をご参照ください。
https://developer.apple.com/documentation/uikit/uipasteboard

UISearchTextFieldDelegateの実装

まずUISearchTextFieldDelegateの実装をしていきます。


extension ViewController: UISearchTextFieldDelegate {
    func searchTextField(_ searchTextField: UISearchTextField, itemProviderForCopying token: UISearchToken) -> NSItemProvider {
        guard let plan = token.representedObject as? Plan else {
            return NSItemProvider()
        }
        return NSItemProvider(object: PastePlan(plan))
    }
}

このメソッドはトークンのCopyをタップした際に呼ばれます。

ここではUIPasteboardに設定するNSItemProviderを用意しています。

NSItemProviderの説明は割愛させていただきます。
↓をご参照ください。
https://developer.apple.com/documentation/foundation/nsitemprovider

取得したい情報は
上記のsetTokenメソッドの中で設定した
representedObjectから取得しています。

delegateの設定も忘れないように行います。


searchBar.searchTextField.delegate = self

UIPasteboardへ値の書き込みと読み込み

次にUIPasteboardへ値の書き込みと読み込みをするための実装をしていきます。

そのためにUITextPasteDelegateを実装します。
https://developer.apple.com/documentation/uikit/uitextpastedelegate

使用するメソッドはUITextPasteItemが取得できる


textPasteConfigurationSupporting(
    _ textPasteConfigurationSupporting: UITextPasteConfigurationSupporting, 
    transform item: UITextPasteItem)

です。


extension ViewController: UITextPasteDelegate {
    func textPasteConfigurationSupporting(_ textPasteConfigurationSupporting: UITextPasteConfigurationSupporting, transform item: UITextPasteItem) {
        guard let item = item as? UISearchTextFieldPasteItem else {
            return
        }

        item.itemProvider.loadObject(ofClass: PastePlan.self) {
            (pastePlan, error) in
            guard let plan = (pastePlan as? PastePlan)?.plan else {
                return
            }
            let token = UISearchToken(icon: UIImage(systemName: plan.iconName), text: plan.rawValue)
            item.setSearchTokenResult(token)
        }
    }
}

UISearchTextFieldPasteItemはProtocolで
setSearchTokenResult(_ token: UISearchToken)メソッドがあり
ここでトークンをUIPasteboardに設定することができます。

UISearchTextFieldPasteItem
https://developer.apple.com/documentation/uikit/uisearchtextfieldpasteitem

上記のコードで出てきているPastePlan
NSItemProviderに値を設定するために用意したクラスです。
(コードは最後に記載しています。)
内部でPlanを保持するようにしています。

またJSONとして値を取得するために
Codableにも適合するようにしています。

delegateは
searchTextFieldが適合する
UITextPasteConfigurationSupporting
pasteDelegateへの設定が必要になります。


searchBar.searchTextField.pasteDelegate = self

UIPasteboardからの値の取得

最後にUIPasteboardから値を取得します。


private func extractPlanFromPasteboard() -> Plan? {
    guard let data = UIPasteboard.general.data(forPasteboardType: (kUTTypeJSON as String)) else {
        return nil
    }
    UIPasteboard.general.items = []
    return try? JSONDecoder().decode(PastePlan.self, from: data).plan
}


最終的なトークンの設定や表示するデータのフィルターは
UISearchResultsUpdating
updateSearchResults(for searchController: UISearchController)
の中で行っています。

動作

このような実装をすることで
下記のような動作が実現できました。

画面収録-2019-11-24-8.20.58.gif

今回使用したコード

下記に記載もしていますが
gistにもアップロードしました。

全体のコード

import UIKit
import MobileCoreServices

class PastePlan: NSObject, NSItemProviderReading, NSItemProviderWriting, Codable {
    let plan: Plan
    init(_ plan: Plan) {
        self.plan = plan
    }
    static var readableTypeIdentifiersForItemProvider: [String] = [(kUTTypeJSON) as String]
    static var writableTypeIdentifiersForItemProvider: [String] = [(kUTTypeJSON) as String]

    static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> Self {
        try JSONDecoder().decode(self, from: data)
    }

    func loadData(withTypeIdentifier typeIdentifier: String, forItemProviderCompletionHandler completionHandler: @escaping (Data?, Error?) -> Void) -> Progress? {
        let progress = Progress(totalUnitCount: 100)
        do {
            let data = try JSONEncoder().encode(self)
            progress.completedUnitCount = 100
            completionHandler(data, nil)
       } catch {
           completionHandler(nil, error)
       }

       return progress
    }
}

enum Plan: String, CaseIterable, Equatable, Codable {
    case business
    case travel
    case shopping

    init?(_ text: String) {
        if text.starts(with: "bu") {
            self = .business
        } else if text.starts(with: "tr") {
            self = .travel
        } else if text.starts(with: "sh") {
            self = .shopping
        } else {
            return nil
        }
    }

    var iconName: String {
        switch self {
        case .business:
            return "bag"
        case .travel:
            return "airplane"
        case .shopping:
            return "cart"
        }
    }

    var items: [String] {
        switch self {
        case .business:
            return ["会議", "商談", "プレゼン", "勉強会"]
        case .travel:
            return ["宿泊", "日帰り"]
        case .shopping:
            return ["スーパー", "デパート"]
        }
    }
}

class ViewController: UIViewController {
    @IBOutlet weak private var tableView: UITableView!

    private var searchController = UISearchController()

    private var filteredItems: [[String]] = []

    private var searchBar: UISearchBar {
        searchController.searchBar
    }

    private var isSearchBarEmpty: Bool {
        if !searchBar.searchTextField.tokens.isEmpty {
            return false
        }
        return searchBar.text?.isEmpty ?? true
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        setupSearchBar()
        setupTableView()
    }

    private func setupSearchBar() {
        searchController.searchResultsUpdater = self
        searchController.obscuresBackgroundDuringPresentation = false
        searchBar.placeholder = "検索キーワード"
        definesPresentationContext = true

        // UISearchTextField
        searchBar.searchTextField.backgroundColor = .systemOrange
        searchBar.searchTextField.textColor = .systemPurple
        searchBar.searchTextField.font = UIFont(name: "American Typewriter", size: 18)
        searchBar.searchTextField.tokenBackgroundColor = .systemBlue

        // Delete
        searchBar.searchTextField.allowsDeletingTokens = true

        // Copy & Paste
        searchBar.searchTextField.allowsCopyingTokens = true
        searchBar.searchTextField.delegate = self
        searchBar.searchTextField.pasteDelegate = self
    }

    private func setupTableView() {
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
        tableView.dataSource = self
        tableView.tableHeaderView = searchController.searchBar
    }
}

extension ViewController: UISearchTextFieldDelegate {
    func searchTextField(_ searchTextField: UISearchTextField, itemProviderForCopying token: UISearchToken) -> NSItemProvider {
        guard let plan = token.representedObject as? Plan else {
            return NSItemProvider()
        }
        return NSItemProvider(object: PastePlan(plan))
    }
}

extension ViewController: UITextPasteDelegate {
    func textPasteConfigurationSupporting(_ textPasteConfigurationSupporting: UITextPasteConfigurationSupporting, transform item: UITextPasteItem) {
        guard let item = item as? UISearchTextFieldPasteItem else {
            return
        }

        item.itemProvider.loadObject(ofClass: PastePlan.self) {
            (pastePlan, error) in
            guard let plan = (pastePlan as? PastePlan)?.plan else {
                return
            }
            let token = UISearchToken(icon: UIImage(systemName: plan.iconName), text: plan.rawValue)
            item.setSearchTokenResult(token)
        }
    }
}

extension ViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        if isSearchBarEmpty {
            return Plan.allCases[section].items.count
        }
        if !filteredItems.isEmpty {
            return filteredItems[section].count
        }
        return 0
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        var item: String? = nil
        if isSearchBarEmpty {
            item = Plan.allCases[indexPath.section].items[indexPath.row]
        } else if !filteredItems.isEmpty {
            item = filteredItems[indexPath.section][indexPath.row]
        }
        cell.textLabel?.text = item
        return cell
    }
}

extension ViewController: UITableViewDelegate {
    func numberOfSections(in tableView: UITableView) -> Int {
        if isSearchBarEmpty {
            return Plan.allCases.count
        }

        if !filteredItems.isEmpty {
            return filteredItems.count
        }

        return 0
    }

    func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
        Plan.allCases[section].rawValue
    }
}

extension ViewController: UISearchResultsUpdating {
    func updateSearchResults(for searchController: UISearchController) {
        var text = searchBar.text!.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
        guard !isSearchBarEmpty else {
            return
        }

        var searchPlans: [Plan] = extractSearchPlans()
        if let plan = Plan(text) {
            setToken(from: plan)
            searchPlans.append(plan)
            text = ""
        } else if let plan = extractPlanFromPasteboard() {
            searchPlans.append(plan)
            text = ""
        }
        updateUI(plans: searchPlans, text: text)
    }

    private func extractPlanFromPasteboard() -> Plan? {
        guard let data = UIPasteboard.general.data(forPasteboardType: (kUTTypeJSON as String)) else {
            return nil
        }
        UIPasteboard.general.items = []
        return try? JSONDecoder().decode(PastePlan.self, from: data).plan
    }

    private func extractSearchPlans() -> [Plan] {
        searchBar.searchTextField.tokens.compactMap { $0.representedObject as? Plan }
    }

    private func setToken(from plan: Plan) {
        let planToken = UISearchToken(icon: UIImage(systemName: plan.iconName), text: plan.rawValue)
        planToken.representedObject = plan
        let field = searchBar.searchTextField
        field.replaceTextualPortion(of: field.textualRange, with: planToken, at: field.tokens.count)
    }

    private func updateUI(plans: [Plan], text: String) {
        filteredItems = []
        if !plans.isEmpty {
            filteredItems = Plan.allCases.filter { plans.contains($0) }
                .map {
                    $0.items.filter { $0.starts(with: text) }
            }
        } else {
            filteredItems = Plan.allCases.map {
                    $0.items.filter { $0.starts(with: text) } }
        }
        tableView.reloadData()
    }
}

まとめ

iOS13のUISearchBarの新機能について見ていきました。

特にUISearchTokenに関しては
検索キーワードを一つのまとまりとして扱ったりすることができ
使い道がたくさんあるのではないかと思います。

ただ現在情報が見つからないため
正式なサンプルやドキュメントなどが
早く出てくると嬉しいですね😃

もし間違いなどございましたら教えていただけますとうれしいです🙇🏻‍♂️

86
52
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
86
52