Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

SwiftUI Combine Debounce TextField

I have a SwiftUI app with SwiftUI App life cycle. I'm trying to setup a standard way to add typing debounce to TextFields. Ideally, I'd like to create my own TextField modifier that can easily be applied to views that have many textfields to edit. I've tried a bunch of ways to do this but I must be missing something fundamental. Here's one example. This does not work:

struct ContentView: View {

    @State private var searchText = ""
    
    var body: some View {
    
        VStack {
            Text("You entered: \(searchText)")
                .padding()
            TextField("Enter Something", text: $searchText)
                .frame(height: 30)
                .padding(.leading, 5)
                .overlay(
                    RoundedRectangle(cornerRadius: 6)
                        .stroke(Color.blue, lineWidth: 1)
                )
                .padding(.horizontal, 20)
                .onChange(of: searchText, perform: { _ in
                    var subscriptions = Set<AnyCancellable>()
                
                    let pub = PassthroughSubject<String, Never>()
                    pub
                        .debounce(for: .seconds(1), scheduler: DispatchQueue.main)
                        .collect()
                        .sink(receiveValue: { t in
                            self.searchText = t.first ?? "nothing"
                        } )
                        .store(in: &subscriptions)
                })
        }
    }
}

Any guidance would be appreciated. Xcode 12.4, iOS 14.4

like image 711
JohnSF Avatar asked Feb 12 '21 00:02

JohnSF


2 Answers

I think you'll have to keep two variables: one for the text in the field as the user is typing and one for the debounced text. Otherwise, the user wouldn't see the typing coming in in real-time, which I'm assuming isn't the behavior you want. I'm guessing this is probably for the more standard use case of, say, performing a data fetch once the user has paused their typing.

I like ObservableObjects and Combine to manage this sort of thing:

class TextFieldObserver : ObservableObject {
    @Published var debouncedText = ""
    @Published var searchText = ""
    
    private var subscriptions = Set<AnyCancellable>()
    
    init() {
        $searchText
            .debounce(for: .seconds(1), scheduler: DispatchQueue.main)
            .sink(receiveValue: { [weak self] t in
                self?.debouncedText = t
            } )
            .store(in: &subscriptions)
    }
}

struct ContentView: View {
    @StateObject var textObserver = TextFieldObserver()
    
    @State var customText = ""
    
    var body: some View {
    
        VStack {
            Text("You entered: \(textObserver.debouncedText)")
                .padding()
            TextField("Enter Something", text: $textObserver.searchText)
                .frame(height: 30)
                .padding(.leading, 5)
                .overlay(
                    RoundedRectangle(cornerRadius: 6)
                        .stroke(Color.blue, lineWidth: 1)
                )
                .padding(.horizontal, 20)
            Divider()
            Text(customText)
            TextFieldWithDebounce(debouncedText: $customText)
        }
    }
}
struct TextFieldWithDebounce : View {
    @Binding var debouncedText : String
    @StateObject private var textObserver = TextFieldObserver()
    
    var body: some View {
    
        VStack {
            TextField("Enter Something", text: $textObserver.searchText)
                .frame(height: 30)
                .padding(.leading, 5)
                .overlay(
                    RoundedRectangle(cornerRadius: 6)
                        .stroke(Color.blue, lineWidth: 1)
                )
                .padding(.horizontal, 20)
        }.onReceive(textObserver.$debouncedText) { (val) in
            debouncedText = val
        }
    }
}

I included two examples -- the top, where the container view (ContentView) owns the ObservableObject and the bottom, where it's made into a more-reusable component.

like image 112
jnpdx Avatar answered Oct 20 '22 21:10

jnpdx


A little simplified version of text debouncer from @jnpdx

Note that .assign(to: &$debouncedText) doesn't create a reference cycle and manages subscription for you automatically

class TextFieldObserver : ObservableObject {
    
    @Published var debouncedText = ""
    @Published var searchText = ""
        
    init(delay: DispatchQueue.SchedulerTimeType.Stride) {
        $searchText
            .debounce(for: delay, scheduler: DispatchQueue.main)
            .assign(to: &$debouncedText)
    }
}
like image 9
Ivan Kvyatkovskiy Avatar answered Oct 20 '22 21:10

Ivan Kvyatkovskiy