简体   繁体   English

SwiftUI 组合去抖动文本字段

[英]SwiftUI Combine Debounce TextField

I have a SwiftUI app with SwiftUI App life cycle.我有一个具有 SwiftUI 应用程序生命周期的 SwiftUI 应用程序。 I'm trying to setup a standard way to add typing debounce to TextFields.我正在尝试设置一种标准方法来向 TextFields 添加打字去抖动。 Ideally, I'd like to create my own TextField modifier that can easily be applied to views that have many textfields to edit.理想情况下,我想创建自己的 TextField 修饰符,可以轻松地将其应用于具有许多要编辑的文本字段的视图。 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 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:我喜欢 ObservableObjects 和 Combine 来管理这类事情:

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.我包括了两个例子——顶部,容器视图( ContentView )拥有 ObservableObject 和底部,它被制成一个更可重用的组件。

A little simplified version of text debouncer from @jnpdx来自@jnpdx 的文本去抖动器的简化版本

Note that .assign(to: &$debouncedText) doesn't create a reference cycle and manages subscription for you automatically请注意, .assign(to: &$debouncedText)不会创建参考周期并自动为您管理订阅

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.如果您无法使用ObservableObject (即,如果您的视图由 state 机器驱动,或者您正在将输入结果传递给委托,或者只是发布输入),有一种方法可以使用只查看代码。 This is done by forwarding text changes to a local Publisher , then debouncing the output of that Publisher .这是通过将文本更改转发到本地Publisher来完成的,然后对该Publisher的 output 进行去抖动。

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.但是,如果您可以选择,使用ObservableObject方法对 go 可能更“正确”。

Tunous on GitHub added a debounce extension to onChange recently. GitHub 上的 Tunous 最近为 onChange 添加了去抖动扩展。 https://github.com/Tunous/DebouncedOnChange that is super simple to use. https://github.com/Tunous/DebouncedOnChange使用起来超级简单。 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) }而不是 add.onChange(of: value) {newValue in doThis(with: newValue) } 你可以 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.他设置了一个在 debounceTime 时间内休眠的任务,但它会在每次更改值时被取消并重置。 The view modifier he created uses a State var debounceTask.他创建的视图修改器使用了 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.我突然想到,这个任务可能是一个 Binding 而不是共享数量的多个 onChange 视图修饰符,允许在同一个 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.这样,如果您使用相同的 debounceTask 以编程方式更改一堆文本字段,则只会调用一次操作,这通常是人们想要做的。 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.我还没有尝试使用 ObservedObject 或 StateObject 的共享 debounceTask 绑定,只是 State var。 If anyone tries that please post the result.如果有人尝试,请发布结果。

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

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