【SwiftUI】TextField で debounce | Debouncing TextField

みんなは、どんな実装にしていますか。

入力文字を監視しつつの検索結果のリアルタイム反映的なやつを作るとき。

詰まる感じをどう解消していますか。

Debounce
only emit an item from an Observable if a particular timespan has passed without it emitting another item

👉 ReactiveX - Debounce operator hatena-bookmark

👉 debounce(for:scheduler:options:) | Apple Developer Documentation hatena-bookmark


import Combine

public final class DebounceObject: ObservableObject {
  @Published var text: String = ""
  @Published var debouncedText: String = ""
  private var bag = Set<AnyCancellable>()

  public init(dueTime: TimeInterval = 0.5) {
    $text
      .removeDuplicates()
      .debounce(for: .seconds(dueTime), scheduler: DispatchQueue.main)
      .sink(receiveValue: { [weak self] value in
        self?.debouncedText = value
      })
      .store(in: &bag)
  }
}


struct SearchView: View {
  @StateObject var debounceObject = DebounceObject()

  var body: some View {
    VStack {
      TextField(text: $debounceObject.text)
        .onChange(of: debounceObject.debouncedText) { text in
          // perform search
        }
    }
  }
}

👉 How to debounce TextField search in SwiftUI | Swift Discovery hatena-bookmark

combine てのもう時代遅れなのですか ?

今現在、本流の本筋の考え方のわかりやすいシンプルな記述を探します。

 

🤔 Tunous/DebouncedOnChange

直感的に使いやすい ViewModifier ライブラリです。

👉 Tunous/DebouncedOnChange: SwiftUI onChange View extension with debounce time hatena-bookmark

コード量が少ないので内部も把握しやすいです。

基本的な使い方は、0.25 秒をデバウンス時間として以下のように書けます。


TextField("Search-1", text: $text)
  .onChange(of: text, debounceTime: .seconds(0.25)) { newValue in
    debouncedText = newValue
  }

使ってみると分かるのは、View 内に @State として、

textdebouncedText

の2つが存在してしまうことが面倒です。

なので、それを避けるために、

DebounceTextField として View を外出しにして使います。


DebounceTextField2(titleKey: "Search-2", debouncedText: $debouncedText)


struct DebounceTextField2: View {
  var titleKey: String
  @Binding var debouncedText: String

  @State private var local = ""

  var body: some View {
    TextField(titleKey, text: $local)
      .onChange(of: local, debounceTime: .seconds(0.25)) { newValue in
        debouncedText = newValue
      }
  }
}

onChange() を使うのはどうなのか。

 

🤔 Binding をカスタムする

そもそも @State は、

@Binding な引数をもつコンポーネントに対して双方向に値が流れる

ということをきちんと頭に置きながら考えてみます。



struct DebounceTextField3: View {
  var titleKey: String
  @Binding var debouncedText: String

  @State private var delay: Task<Void, Never>?

  var body: some View {
    TextField(
      titleKey,
      text: Binding(
        get: { debouncedText },
        set: { newvalue in
          delay?.cancel()
          delay = Task {
            do {
              try await Task.sleep(for: .seconds(0.25))
            } catch { return }
            debouncedText = newvalue
          }
        }
      )
    )
  }
}

続いて、ちょっとしっかりしたライブラリを使ってみます。

👉 boraseoksoon/Throttler: One Line to throttle, debounce and delay: Say Goodbye to Reactive Programming such as RxSwift and Combine. hatena-bookmark


struct DebounceTextField4: View {
  var titleKey: String
  @Binding var debouncedText: String

  var body: some View {
    TextField(
      titleKey,
      text: Binding(
        get: { debouncedText },
        set: { newvalue in
          debounce(.seconds(0.25)) {
            debouncedText = newvalue
          }
        }
      )
    )
  }
}

 

🤔 まとめ

Custom Binding を使ったほうがスッキリします。



【SwiftUI】ScrollView や List を snap する


作ってみると、何かがきもい選択肢リストの挙動。

上下の padding 付近が気になります。

 

🧑🏻‍💻 snap する

スクロールしたときの

選択肢のアイテムを

きりのいいところで止まるようにします。

ScrollView を使ったリストの実装に、

.scrollTargetLayout()

.scrollTargetBehavior(.viewAligned)

の2行を追加するだけできっちり止まるようになります。

👉 ScrollTargetBehavior | Apple Developer Documentation hatena-bookmark



計算とかしなくていいです。

便利。

ページングもできるとかすげえ。

👉 [WWDC2023] iOS17におけるScrollViewの新機能  その1 hatena-bookmark
👉 UICollectionView with Snapping and Scaling in Swift | by Satsuki Hashiba | Medium hatena-bookmark

 

🧑🏻‍💻 結果

ScrollView 上下のきわきわがすっきりしました !

しかし、「snap」て難しい英単語ですね。

👉 snapの意味・使い方・読み方|英辞郎 on the WEB hatena-bookmark

「スナップエンドウ」てやつ、

前から気にはなってました。


【SwiftUI】shadow() が内側まで効いて困る - compositingGroup()

こんな表示。


VStack {
  Text("こちらへ")
  Text("▶")
}
.padding()
.background(.yellow)

看板ぽく影を付けます。

.shadow() ですね !

簡単ですね !


VStack {
  Text("こちらへ")
  Text("▶")
}
.padding()
.background(.yellow)
.shadow(color: .black, radius: 2, x: 5, y: 5) // *

...

なんでや !?

VStack だけ効けばいいのに、

内側の文字まで効いてますが。

 

👩🏼‍💻 対応方法

.shadow() の前で、

.compositingGroup()

が良さげです。

Wraps this view in a compositing group.

👉 compositingGroup() | Apple Developer Documentation hatena-bookmark


VStack {
  Text("こちらへ")
  Text("▶")
}
.padding()
.background(.yellow)
.compositingGroup() // *
.shadow(color: .black, radius: 2, x: 5, y: 5)

compositingGroup() の代わりに、

.clipShape(.rect)

.clipped()

でも、内側 Text() への .shadow() の影響を

切り離すことができるようです。

しかし、

なんで .shadow() だけなんやろな。

他は、この方法で影響を切り離すことはできない模様。



そもそもは、文字がなぜかぼやけてる? てのがきっかけでした。