[SwiftUI][Combine] debounce の使い方 (TextField を例として)

Combine の debounce を説明します。

環境&対象

以下の環境で動作確認を行なっています。

  • macOS Big Sur 11.2.3
  • Xcode 12.4
  • iOS 14.4

SwiftUI の TextField

SwiftUI の TextField は、String の Binding を受け取り、ユーザー入力を反映してくれます。

TextField のユーザー入力の反映タイミング

ユーザー入力の変数への反映は、即時です。

"Hello" とタイプされると、"H", "He", "Hel", "Hell", "Hello" というように値がアップデートされていきます。

すぐに修正した打ち間違いも、きちんと反映されます。

TextField example

struct ContentView: View {
    @State private var text = ""

    var body: some View {
        VStack {
            GroupBox(label: Text("TextField")) {
                TextField("text", text: $text)
                Text("input text: \(text)")
                    .padding()
            }
        }
        .padding()
    }
}

懸念点1:入力をトリガーとする処理実行

例えば、TextField を検索フィールドに使っているケースを考えてみます。
最近のアプリでは、検索フィールドに対して、入力終了(検索ボタン押下やリターンキー入力)を待たずにダイナミックに検索することが期待されている気がします。

上記のような入力がされた時にどのタイミングで検索を実行するのが良いでしょうか?

アプリケーション的には、1文字増えるごとに検索をかけたくないかもしれません。

ユーザーが、素早く入力した時を想定すると、"H" を検索しているあいだに、"He", "Hel" と文字が増えてしまい、検索が追いつかなくなることも考えられます。

そう考えると、ユーザーの入力を 何らかの形でまとめてくれると嬉しいケースがありそうです。

懸念点2:UNDO/REDO バッファの複雑化

例えば、UNDO/REDO できるアプリケーションと組み合わせたケースを考えてみます。

"Hello" とタイプされた時に、アプリのデータも入力に合わせて、"H", "He", "Hel", "Hell", "Hello" と更新してしまうと、
"Hello" から UNDO すると "Hell" となってしまいます。

ユーザーの期待値はさまざまだと思いますが、ここでもユーザーの入力を何らかの形でまとめてくれると嬉しいケースがありそうです。

Debounce

Combine のオペレータ に debounce というものがあります。

イベントを保持し、一定時間 イベントのアップデートがなければその時点の最新値を送信するというものです。

Apple のドキュメントは、こちら

英語ですが 絵も使って説明されている Blog も見つけました、こちらです。

この debounce を使って、TextField の 入力ごとに行われる変数アップデートを まとめることができます。

TextField と Debounce を組み合わせる

SwiftUI の TextField と Combine の debounce を組み合わせていきます。

DebouncedTextProvider の定義

まずは、変更される String を debounce 経由で外部に提供するクラスを作ります。

DebouncedTextProvider という名前にしました。

DebouncedTextProvider

import SwiftUI
import Combine

class DebouncedTextProvider: ObservableObject {
    // (1)
    @Published var typingText: String = ""
    // (2)
    @Published var typedText: String = ""     
    private var cancellables: Set = Set()
    
    init() {
        $typingText
            // (3)
            .debounce(for: 0.5, scheduler: DispatchQueue.main)
            .sink { value in
                // (4)
                if self.typedText != value {
                    // (5)
                    self.typedText = value
                }
            }
            // (6)
            .store(in: &cancellables)
    }
}
コード解説
  1. タイプ中の文字列を受ける変数
  2. 外部にタイプされたとして渡す変数
  3. ここで debounce を使い、変更を一定時間 留保します
  4. 不必要な変数更新を抑止しています
  5. 一定時間後に、変更を反映します
  6. subscribe していることをキープします。(Combine を使うときのほとんど定型文ですね)

外部からは、typedText をユーザーが入力した値として使うことを想定しています。

typingText は、実際のユーザー入力に合わせてアップデートされる変数です。

DebouncedTextProvider を使った実装

DebouncedTextProvider と TextField を組み合わせてみます。

DebouncedTextProvider, TextField example

struct ContentView: View {
    @State private var text = ""
    // (1)
    @StateObject var textProvider = DebouncedTextProvider()

    var body: some View {
        VStack {
            GroupBox(label: Text("TextField")) {
                TextField("text", text: $text)
                Text("input text: \(text)")
                    .padding()
            }
            GroupBox(label: Text("BouncedTextProvider")) {
                // (2)
                TextField("text", text: $textProvider.typingText) { bool in
                } onCommit: {
                    // (3)
                    textProvider.typedText = textProvider.typingText
                }
                .padding()
                Text("typing text(direct): \(textProvider.typingText)")
                    .padding()
                Text("typed text(bounced): \(textProvider.typedText)")
                    .padding()
            }
        }
        .padding()
    }
}
コード解説
  1. DebouncedTextProvider を用意します
  2. TextField には、textProvider の typingText を渡します
  3. 終了処理(リターンキー押下)された時には、debounce 時間を待たずに、変更を反映します

以下のような動作になります。debounce に 0.5 秒を指定しているため、少し遅れて(0.5秒) typedText に反映されているのがわかります。

DebouncedTextField

このままだと、TextField を使うたびに煩雑なので、カスタムビューを作ります。

次に、DebouncedTextProvider を使う TextField を定義します。

DebouncedTextField

struct DebouncedTextField: View {
    // (1)
    @StateObject var textProvider = DebouncedTextProvider()
    // (2)
    @Binding var typedText: String

    init(_ text: Binding) {
        self._typedText = text
    }
    
    var body: some View {
        // (3)
        TextField("TextFieldTitle", text: $textProvider.typingText) { bool in
            // empty for onEditing
        } onCommit: {
            // (4)
            if (typedText != textProvider.typingText) {
                // (5)
                typedText = textProvider.typingText
            }
        }
        .onReceive(textProvider.$typedText) { newValue in
            // (6)
            if (typedText != newValue) {
                // (7)
                typedText = newValue
            }
        }
    }
}
コード解説
  1. DebouncedTextProvider を用意します
  2. 外部から受け取る Binding<String> です
  3. TextField を BouncedTextProvider と合わせて使います
  4. 同じ値で変数を更新しないようにチェック
  5. 編集終了処理(リターンキーを入力された)時には、debounce の時間を待たず、Binding された変数を更新します
  6. 同じ値で変数を更新しないようにチェック
  7. bounce による変数更新通知を受けて、Binding された変数を更新します
MEMO
この実装では、編集終了処理(リターンキー)と debounce での変更のどちらが先に来るかわからないため、同値チェックを入れて、不必要な変数の更新を防いでいます。

まとめ:イベント を時系列でまとめるには debounce

イベント を時系列でまとめるには debounce
  • debounce を使うことで、送信されてくるイベントを時系列にまとめることができます
  • TextField による 変数の即時更新は、debounce を使うと、頻度を下げることができる

説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です