簡體   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