繁体   English   中英

SwiftUI 组合去抖动文本字段

[英]SwiftUI Combine Debounce TextField

我有一个具有 SwiftUI 应用程序生命周期的 SwiftUI 应用程序。 我正在尝试设置一种标准方法来向 TextFields 添加打字去抖动。 理想情况下,我想创建自己的 TextField 修饰符,可以轻松地将其应用于具有许多要编辑的文本字段的视图。 我已经尝试了很多方法来做到这一点,但我一定错过了一些基本的东西。 这是一个例子。 这不起作用:

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

任何指导将不胜感激。 Xcode 12.4,iOS 14.4

我认为您必须保留两个变量:一个用于用户键入时字段中的文本,另一个用于去抖动文本。 否则,用户将看不到实时输入的输入,我假设这不是您想要的行为。 我猜这可能是针对更标准的用例,例如,一旦用户暂停输入,就执行数据提取。

我喜欢 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
        }
    }
}

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

来自@jnpdx 的文本去抖动器的简化版本

请注意, .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)
    }
}

如果您无法使用ObservableObject (即,如果您的视图由 state 机器驱动,或者您正在将输入结果传递给委托,或者只是发布输入),有一种方法可以使用只查看代码。 这是通过将文本更改转发到本地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)
            }
    }
}

或者如果广播更改:

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

但是,如果您可以选择,使用ObservableObject方法对 go 可能更“正确”。

GitHub 上的 Tunous 最近为 onChange 添加了去抖动扩展。 https://github.com/Tunous/DebouncedOnChange使用起来超级简单。 而不是 add.onChange(of: value) {newValue in doThis(with: newValue) } 你可以 add.onChange(of: value, debounceTime: 0.8 / sec / ) {newValue in doThis(with: newValue) }

他设置了一个在 debounceTime 时间内休眠的任务,但它会在每次更改值时被取消并重置。 他创建的视图修改器使用了 State var debounceTask。 我突然想到,这个任务可能是一个 Binding 而不是共享数量的多个 onChange 视图修饰符,允许在同一个 debounce 上修改许多文本字段。 这样,如果您使用相同的 debounceTask 以编程方式更改一堆文本字段,则只会调用一次操作,这通常是人们想要做的。 这是带有一个简单示例的代码。

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

我还没有尝试使用 ObservedObject 或 StateObject 的共享 debounceTask 绑定,只是 State var。 如果有人尝试,请发布结果。

暂无
暂无

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

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