如何创建只接受数字和单个点的 SwiftUI TextField?

Posted

技术标签:

【中文标题】如何创建只接受数字和单个点的 SwiftUI TextField?【英文标题】:How to create SwiftUI TextField that accepts only numbers and a single dot? 【发布时间】:2019-09-06 13:13:45 【问题描述】:

如何创建一个允许用户只输入数字和单个点的 swiftui 文本字段? 换句话说,它会在用户输入时逐位检查,如果输入是数字或点并且文本字段没有另一个点,则接受该数字,否则忽略数字输入。 不能使用步进器。

【问题讨论】:

您也许可以使用格式化程序来完成,尽管我对它们没有太大的成功。我使用的方法是创建一个带有字符串的 ObservableObject,测试 didSet 中的值,如果它们不符合您的模式则更新。 用更多细节编辑了我的答案。 【参考方案1】:

SwiftUI 不允许您为 TextField 指定一组允许的字符。实际上,这与 UI 本身无关,而与您如何管理背后的模型有关。在这种情况下,模型是TextField 后面的文本。因此,您需要更改视图模型。

如果您在@Published 属性上使用$ 符号,您可以访问@Published 属性本身后面的Publisher。然后,您可以将自己的订阅者附加到发布者并执行您想要的任何检查。在这种情况下,我使用 sink 函数将基于闭包的订阅者附加到发布者:

/// Attaches a subscriber with closure-based behavior.
///
/// This method creates the subscriber and immediately requests an unlimited number of values, prior to returning the subscriber.
/// - parameter receiveValue: The closure to execute on receipt of a value.
/// - Returns: A cancellable instance; used when you end assignment of the received value. Deallocation of the result will tear down the subscription stream.
public func sink(receiveValue: @escaping ((Self.Output) -> Void)) -> AnyCancellable

实现:

import SwiftUI
import Combine

class ViewModel: ObservableObject 
    @Published var text = ""
    private var subCancellable: AnyCancellable!
    private var validCharSet = CharacterSet(charactersIn: "1234567890.")

    init() 
        subCancellable = $text.sink  val in
            //check if the new string contains any invalid characters
            if val.rangeOfCharacter(from: self.validCharSet.inverted) != nil 
                //clean the string (do this on the main thread to avoid overlapping with the current ContentView update cycle)
                DispatchQueue.main.async 
                    self.text = String(self.text.unicodeScalars.filter 
                        self.validCharSet.contains($0)
                    )
                
            
        
    

    deinit 
        subCancellable.cancel()
    


struct ContentView: View 
    @ObservedObject var viewModel = ViewModel()

    var body: some View 
        TextField("Type something...", text: $viewModel.text)
    

需要注意的是:

$text$@Published 属性上签名)为我们提供了Published<String>.Publisher 类型的对象,即发布者 $viewModel.text$@ObservableObject 上签名)给了我们一个Binding<String> 类型的对象

这是两个完全不同的东西。

编辑:如果您愿意,您甚至可以使用此行为创建您自己的自定义TextField。假设您要创建一个DecimalTextField 视图:

import SwiftUI
import Combine

struct DecimalTextField: View 
    private class DecimalTextFieldViewModel: ObservableObject 
        @Published var text = ""
        private var subCancellable: AnyCancellable!
        private var validCharSet = CharacterSet(charactersIn: "1234567890.")

        init() 
            subCancellable = $text.sink  val in                
                //check if the new string contains any invalid characters
                if val.rangeOfCharacter(from: self.validCharSet.inverted) != nil 
                    //clean the string (do this on the main thread to avoid overlapping with the current ContentView update cycle)
                    DispatchQueue.main.async 
                        self.text = String(self.text.unicodeScalars.filter 
                            self.validCharSet.contains($0)
                        )
                    
                
            
        

        deinit 
            subCancellable.cancel()
        
    

    @ObservedObject private var viewModel = DecimalTextFieldViewModel()

    var body: some View 
        TextField("Type something...", text: $viewModel.text)
    


struct ContentView: View 
    var body: some View 
        DecimalTextField()
    

这样你就可以使用你的自定义文本字段来写:

DecimalTextField()

你可以在任何你想要的地方使用它。

【讨论】:

如何在现有应用中使用?即 DecimalTextField($myValue) ? @caram 我做了一个working example of that。它并不完美,可以改进,但总比没有好。我在那里使用它:DecimalTextField("123", numericValue: $numeric) 非常感谢您提供这个非常有用的答案,它教会了我很多关于 Combine 和 SwiftUI 使用 pub/sub 的知识。我相信这种方法可以简化以消除显式订阅和由此产生的线程问题。请参阅 this gist 以获取包含使用示例的工作示例。【参考方案2】:

这是 TextField 验证的简单解决方案:(更新)

struct ContentView: View 
@State private var text = ""

func validate() -> Binding<String> 
    let acceptableNumbers: String = "0987654321."
    return Binding<String>(
        get: 
            return self.text
    ) 
        if CharacterSet(charactersIn: acceptableNumbers).isSuperset(of: CharacterSet(charactersIn: $0)) 
            print("Valid String")
            self.text = $0
         else 
            print("Invalid String")
            self.text = $0
            self.text = ""
        
    


var body: some View 
    VStack 
        Spacer()
        TextField("Text", text: validate())
            .padding(24)
        Spacer()
    
  

【讨论】:

【参考方案3】:

我认为使用异步调度是错误的方法,可能会导致其他问题。这是一个使用Double-backed 属性实现相同目的的实现,并在您每次在绑定视图中键入时手动迭代字符。

final class ObservableNumber: ObservableObject 

    let precision: Int

    @Published
    var value: String 
        didSet 
            var decimalHit = false
            var remainingPrecision = precision
            let filtered = value.reduce(into: "")  result, character in

                // If the character is a number that by adding wouldn't exceed the precision and precision is set then add the character.
                if character.isNumber, remainingPrecision > 0 || precision <= 0 
                    result.append(character)

                    // If a decimal has been hit then decrement the remaining precision to fulfill
                    if decimalHit 
                        remainingPrecision -= 1
                    

                // If the character is a decimal, one hasn't been added already, and precision greater than zero then add the decimal.
                 else if character == ".", !result.contains("."), precision > 0 
                    result.append(character)
                    decimalHit = true
                
            

            // Only update value if after processing it is a different value.
            // It will hit an infinite loop without this check since the published event occurs as a `willSet`.
            if value != filtered 
                value = filtered
            
        
    

    var doubleValue: AnyPublisher<Double, Never> 
        return $value
            .map  Double($0) ?? 0 
            .eraseToAnyPublisher()
    

    init(precision: Int, value: Double) 
        self.precision = precision
        self.value = String(format: "%.\(precision)f", value)
    

此解决方案还确保您只有一个小数,而不是允许"." 的多个实例。

注意额外的计算属性将其“放回”到Double。这使您可以继续将数字作为数字而不是String 做出反应,并且必须在任何地方进行转换/转换。您可以很容易地添加任意数量的计算属性,只要您以您期望的方式对其进行转换,就可以像 Int 或任何数字类型一样对其做出反应。

还有一点提示您也可以将其设为泛型 ObservableNumber&lt;N: Numeric&gt; 并处理不同的输入,但使用 Double 并将泛型排除在外将简化其他事情。根据您的需要进行更改。

【讨论】:

以上是关于如何创建只接受数字和单个点的 SwiftUI TextField?的主要内容,如果未能解决你的问题,请参考以下文章

SwiftUI 列出单个可选项

如何只接受在 Swift 中使用泛型的运算符的数字?

为啥导航栏中的 SwiftUI TextField 一次只接受输入一个字符

如何创建 SwiftUI RoundedStar 形状?

如何知道小部件是不是只接受数字字符

如何使 Pyqt5 QLineEdit 只接受数字