skip to Main Content

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

4

Answers


  1. 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.

    Login or Signup to reply.
  2. 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)
        }
    }
    
    Login or Signup to reply.
  3. 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.

    Login or Signup to reply.
  4. 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.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search