简体   繁体   中英

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

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.

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)
    }
}

If you are not able to use an ObservableObject (ie, if your view is driven by a state machine, or you are passing the input results to a delegate, or are simply publishing the input), there is a way to accomplish the debounce using only view code. This is done by forwarding text changes to a local Publisher , then debouncing the output of that Publisher .

struct SomeView: View {
    @State var searchText: String = ""
    let searchTextPublisher = PassthroughSubject<String, Never>()

    var body: some View {
        TextField("Search", text: $searchText)
            .onChange(of: searchText) { searchText in
                searchTextPublisher.send(searchText)
            }
            .onReceive(
                searchTextPublisher
                    .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
            ) { debouncedSearchText in
                print(debouncedSearchText)
            }
    }
}

Or if broadcasting the changes:

struct DebouncedSearchField: View {
    @Binding var debouncedSearchText: String
    @State private var searchText: String = ""
    private let searchTextPublisher = PassthroughSubject<String, Never>()
        
    var body: some View {
        TextField("Search", text: $searchText)
            .onChange(of: searchText) { searchText in
                searchTextPublisher.send(searchText)
            }
            .onReceive(
                searchTextPublisher
                    .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
            ) { debouncedSearchText in
                self.debouncedSearchText = debouncedSearchText
            }
    }
}

However, if you have the choice, it may be more "correct" to go with the ObservableObject approach.

Tunous on GitHub added a debounce extension to onChange recently. https://github.com/Tunous/DebouncedOnChange that is super simple to use. Instead of adding.onChange(of: value) {newValue in doThis(with: newValue) } you can add.onChange(of: value, debounceTime: 0.8 / sec / ) {newValue in doThis(with: newValue) }

He sets up a Task that sleeps for the debounceTime but it is cancelled and reset on every change to value. The view modifier he created uses a State var debounceTask. It occurred to me that this task could be a Binding instead and shared amount multiple onChange view modifiers allowing many textfields to be modified on the same debounce. This way if you programmatically change a bunch of text fields using the same debounceTask only one call to the action is made, which is often what one wants to do. Here is the code with a simple example.

//
//  Debounce.swift
//
//  Created by Joseph Levy on 7/11/22.
//  Based on https://github.com/Tunous/DebouncedOnChange

import SwiftUI
import Combine

extension View {

    /// Adds a modifier for this view that fires an action only when a time interval in seconds represented by
    /// `debounceTime` elapses between value changes.
    ///
    /// Each time the value changes before `debounceTime` passes, the previous action will be cancelled and the next
    /// action /// will be scheduled to run after that time passes again. This mean that the action will only execute
    /// after changes to the value /// stay unmodified for the specified `debounceTime` in seconds.
    ///
    /// - Parameters:
    ///   - value: The value to check against when determining whether to run the closure.
    ///   - debounceTime: The time in seconds to wait after each value change before running `action` closure.
    ///   - action: A closure to run when the value changes.
    /// - Returns: A view that fires an action after debounced time when the specified value changes.
    public func onChange<Value>(
        of value: Value,
        debounceTime: TimeInterval,
        perform action: @escaping (_ newValue: Value) -> Void
    ) -> some View where Value: Equatable {
        self.modifier(DebouncedChangeViewModifier(trigger: value, debounceTime: debounceTime, action: action))
    }
    
    /// Same as above but adds before action
    ///   - debounceTask: The common task for multiple Values, but can be set to a different action for each change
    ///   - action: A closure to run when the value changes.
    /// - Returns: A view that fires an action after debounced time when the specified value changes.
    public func onChange<Value>(
        of value: Value,
        debounceTime: TimeInterval,
        task: Binding< Task<Void,Never>? >,
        perform action: @escaping (_ newValue: Value) -> Void
    ) -> some View where Value: Equatable {
        self.modifier(DebouncedTaskBindingChangeViewModifier(trigger: value, debounceTime: debounceTime, debouncedTask: task, action: action))
    }
}

private struct DebouncedChangeViewModifier<Value>: ViewModifier where Value: Equatable {
    let trigger: Value
    let debounceTime: TimeInterval
    let action: (Value) -> Void

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

    func body(content: Content) -> some View {
        content.onChange(of: trigger) { value in
            debouncedTask?.cancel()
            debouncedTask = Task.delayed(seconds: debounceTime) { @MainActor in
                action(value)
            }
        }
    }
}

private struct DebouncedTaskBindingChangeViewModifier<Value>: ViewModifier where Value: Equatable {
    let trigger: Value
    let debounceTime: TimeInterval
    @Binding var debouncedTask: Task<Void,Never>?
    let action: (Value) -> Void

    func body(content: Content) -> some View {
        content.onChange(of: trigger) { value in
            debouncedTask?.cancel()
            debouncedTask = Task.delayed(seconds: debounceTime) { @MainActor in
                action(value)
            }
        }
    }
}

extension Task {
    /// Asynchronously runs the given `operation` in its own task after the specified number of `seconds`.
    ///
    /// The operation will be executed after specified number of `seconds` passes. You can cancel the task earlier
    /// for the operation to be skipped.
    ///
    /// - Parameters:
    ///   - time: Delay time in seconds.
    ///   - operation: The operation to execute.
    /// - Returns: Handle to the task which can be cancelled.
    @discardableResult
    public static func delayed(
        seconds: TimeInterval,
        operation: @escaping @Sendable () async -> Void
    ) -> Self where Success == Void, Failure == Never {
        Self {
            do {
                try await Task<Never, Never>.sleep(nanoseconds: UInt64(seconds * 1e9))
                await operation()
            } catch {}
        }
    }
}

// MultiTextFields is an example
// when field1, 2 or 3 change the number times is incremented by one, one second later
// when field changes the three other fields are changed too but the increment task only
// runs once because they share the same debounceTask 
struct MultiTextFields: View {
    @State var debounceTask: Task<Void,Never>?
    @State var field: String = ""
    @State var field1: String = ""
    @State var field2: String = ""
    @State var field3: String = ""
    @State var times: Int = 0
    var body: some View {
        VStack {
            HStack {
                TextField("Field", text: $field).padding()
                    .onChange(of: field, debounceTime: 1) { newField in
                        field1 = newField
                        field2 = newField
                        field3 = newField
                    }
                Text(field+" \(times)").padding()
            }
            Divider()
            HStack {
                TextField("Field1", text: $field1).padding()
                    .onChange(of: field1, debounceTime: 1, task: $debounceTask) {_ in
                        times+=1 }
                Text(field1+" \(times)").padding()
            }
            HStack {
                TextField("Field2", text: $field2).padding()
                    .onChange(of: field2, debounceTime: 1, task: $debounceTask) {_ in
                        times+=1 }
                Text(field2+" \(times)").padding()
            }
            HStack {
                TextField("Field3", text: $field3).padding()
                    .onChange(of: field3, debounceTime: 1, task: $debounceTask) {_ in
                        times+=1 }
                Text(field3+" \(times)").padding()
            }
        }
    }
}

struct View_Previews: PreviewProvider {
    static var previews: some View {
        MultiTextFields()
    }
}

I haven't tried the shared debounceTask binding using an ObservedObject or StateObject, just a State var as yet. If anyone tries that please post the result.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM