TextField 单行输入框

TextField 是一个水平扩张型视图,垂直方向不扩张。

参数:占位符

// 第1个参数:是输入框的默认占位字符,会以灰色字样式显示在输入框内
TextField("这里就是输入占位符", text: $textFieldData)

参数:绑定状态属性

为了获取或设置 TextField 中的文本,需要将其绑定到一个变量上。这个变量被传递到 TextField 的初始化器中。然后需要做的就是改变这个绑定变量的文本来改变 TextField 中的内容,或者读取这个绑定变量的值来查看当前 TextField 中的文字。

//1. 先要设置字符串类型的状态变量
@State private var textFieldData = ""

//2. 用$符号绑定变量
TextField("", text: $textFieldData)

// 3. 根据属性的类型不同,需要区别对待,否则会报错

// 如果属性类型是【字符串】,则用 text
TextField("Amount", text: $checkAmount)

// 如果属性类型是【浮点数】等,则用 value
TextField("Amount", value: $checkAmount)

参数:format 格式化

// 显示数字格式(带科学计数法的)
TextField("Enter your score", value: $score, format: .number)

// 显示百分比格式
TextField("Enter your score", value: $score, format: .percent)

// 显示货币格式
TextField("Amount", value: $checkAmount, format: .currency(code: "USD"))
TextField("Amount", value: $checkAmount, format: .currency(code: "CNY"))

// 显示货币格式(下面是设成 .currency ,后面的链式调用&nil合并是设置货币单位,如果无法获取当地货币,则用USD)
TextField("Amount", value: $checkAmount, format: .currency(code: Locale.current.currency?.identifier ?? "USD"))

<aside> 💡 Locale 是内置的一个巨大结构,负责存储用户的所有区域设置——他们使用什么日历、他们如何分隔数字中的千位数字、他们是否使用公制等等。在我们的例子中,我们询问用户是否有首选的货币代码,如果他们没有,我们将回退到“美元”,所以至少我们有一些东西。

</aside>

参数:axis 自适应轴

创建 TextField 时,可以提供一个它可以沿其生长的轴。即 TextField 一开始是单行文本,随着用户键入它可以扩大变成多行输入框。

struct ContentView: View {
    @AppStorage("notes") private var notes = ""
    var body: some View {
        NavigationStack {
            TextField("Enter your text", text: $notes, axis: .vertical)
            // 可以通过添加 lineLimit() 修饰符来控制 TextField 可以增长的大小
            // 例如,我们可能想说它应该以单行开始,但允许最多增长五行:
	          .lineLimit(5)
            .textFieldStyle(.roundedBorder)
        }
    }
}

// 传入范围,代表:始终至少有两行高,但最多可达五行
.lineLimit(2...5)

// 使用 reservesSpace 参数,以便视图自动为其可以拥有的最大大小分配足够的空间
// 例如,这将创建一个 TextField ,它保留足够的布局空间来容纳最多五行文本:
.lineLimit(5, reservesSpace: true)

SecureField 安全文本框

SecureField 是一个水平扩张型视图,垂直方向不扩张。

为了获取或设置 SecureField 中的文本,需要将其绑定到一个变量上。这个变量传递到 SecureField 的初始化器中。然后需要做的就是改变这个绑定变量的文本来改变 SecureField 中的内容。或者读取绑定变量的值来查看 SecureField 中当前的文本。SecureField 的工作原理与常规 TextField 几乎相同,不同之处在于,出于保护隐私的目的,这些字符被屏蔽掉了。

struct ContentView: View {
    @State private var password: String = ""
    var body: some View {
        VStack {
            SecureField("Enter a password", text: $password)
            Text("You entered: \\(password)")
        }
    }
}

TextEditor 多行输入框

当输入简短文本时,TextField 非常有用;但对较长文本,可能需要切换到 TextEditor 视图,它能更好地为用户提供大量的工作空间。

// 例如:将 TextEditor 与 @AppStorage 组合来创建世界上最简单的笔记应用程序
struct ContentView: View {
    @AppStorage("notes") private var notes = ""
    var body: some View {
        NavigationStack {
            TextEditor(text: $notes)
                .navigationTitle("Notes")
        }
    }
}

桥接 UITextView

对于复杂的输入框需求,我们需要通过使用 UIViewRepresentable 包装 UIKit 的 UITextView 类来创建文本视图。例如在纯 SwiftUI 的 TextEditor 视图中,无法直接通过官方 API 获取输入光标的位置并在该位置插入文本。TextEditor 只能绑定一个 String,但 无法感知光标的 selectedRange,也没有像 UITextView 那样的 selectedRange 属性。

关于在 SwiftUI 中使用 UIKit 视图,可以参见这里:桥接 UIKit 视图

https://www.appcoda.com/swiftui-textview-uiviewrepresentable/

1. 设置 TextView 视图

首先要为 UITextView 创建自定义包装器,可以编写如下代码:

import SwiftUI

struct TextView: UIViewRepresentable {

		// 接受两个绑定:一个用于文本输入保存,另一个用于字体样式
    @Binding var text: String
    @Binding var textStyle: UIFont.TextStyle

		// 在 makeUIView 方法中,我们用 textStyle 文本样式初始化文本视图,返回标准的 UITextView
    func makeUIView(context: Context) -> UITextView {
        let textView = UITextView()
        textView.font = UIFont.preferredFont(forTextStyle: textStyle)
        textView.autocapitalizationType = .sentences
        textView.isSelectable = true
        textView.isUserInteractionEnabled = true
        return textView
    }

		// 每当 SwiftUI 中的状态发生变化时,框架都会自动调用 updateUIView 方法来更新视图的配置
    func updateUIView(_ uiView: UITextView, context: Context) {
		    // 1.当在文本视图中键入内容时,我们在这里更新 UITextView 的文本
        uiView.text = text
        // 2.如果调用者对文本样式进行了任何更改,则 UITextView 可以刷新为新样式
        uiView.font = UIFont.preferredFont(forTextStyle: textStyle)
    }

}

2. 设置主视图

现在切换到引用 TextView 的主视图,例如 ContentView.swift

// 1.声明两个状态变量来保存文本输入和文本样式
@State private var message = ""
@State private var textStyle = UIFont.TextStyle.body

// 2.显示文本视图,在 body 中插入以下代码
TextView(text: $message, textStyle: $textStyle)
    .padding(.horizontal)

<aside> 💡

目前可以在文本视图中键入内容,它会显示您键入的内容。但如果尝试打印出 message 变量的值,则它是空的。因为我们尚未将存储在 UITextView 中的文本同步回 message 变量。

</aside>

3. 设置委托

UITextView 有一个名为 UITextViewDelegate 的配套协议,它定义了一组可选方法,您可以使用它们来接收相应 UITextView 对象的编辑更改。具体来说,每当用户在文本视图中键入内容时,都会调用以下方法:

optional func textViewDidChange(_ textView: UITextView)

为了跟踪文本的变化, UITextView 对象应该采用 UITextViewDelegate 协议并实现该方法。前面,我们讨论了 UIViewRepresentable 协议中的几个方法。如果您需要使用 UIKit 中的委托并与 SwiftUI 进行通信,则必须实现 makeCoordinator 方法,并提供 Coordinator 实例。此 Coordinator 会充当 UIView 的委托和 SwiftUI 之间的桥梁。

在 TextView 结构体中,创建一个 Coordinator 类,并实现 makeCoordinator 方法,如下所示:

// makeCoordinator 方法仅返回 Coordinator 的一个实例
func makeCoordinator() -> Coordinator {
    Coordinator($text)
}

// Coordinator 类:采用 UITextViewDelegate 协议并实现 textViewDidChange 方法
// 如上所述,每次用户更改文本时都会调用此方法。因此,我们捕获更新的文本并通过更新 text 绑定将其传回 SwiftUI
class Coordinator: NSObject, UITextViewDelegate {

    var text: Binding<String>

    init(_ text: Binding<String>) {
        self.text = text
    }

    func textViewDidChange(_ textView: UITextView) {
        self.text.wrappedValue = textView.text
    }
    
}

现在我们有了一个遵循 UITextViewDelegate 协议的 Coordinator ,最后还需要再做一个修改。在 makeUIView 方法中,插入以下代码行,将协调器分配给文本视图:

textView.delegate = context.coordinator

完整代码

// TextView.swift
/// 自定义文本编辑器的 UIViewRepresentable 实现
struct TextEditorRepresentable: UIViewRepresentable {

    // 绑定属性:编辑器文本
    @Binding var text: String

    // 绑定属性:当前文本选择范围
    @Binding var selection: NSRange

    // 常量属性:样式配置参数
    let textStyle: UIFont.TextStyle = .title3
    let lineSpacing: CGFloat = 4
    let paragraphSpacing: CGFloat = 12

    // MakeUIView 方法:负责创建和初始化视图对象
    func makeUIView(context: Context) -> UITextView {

        // 声明 UIKit 视图
        let textView = UITextView()
        textView.delegate = context.coordinator

        // 样式设置
        textView.font = UIFont.preferredFont(forTextStyle: textStyle)
        textView.isScrollEnabled = true
        textView.isEditable = true
        textView.isUserInteractionEnabled = true
        // 去除默认边距并设置文本边距
        textView.textContainer.lineFragmentPadding = 0
        textView.textContainerInset = UIEdgeInsets(top: 10, left: 20, bottom: 20, right: 20)
        textView.scrollIndicatorInsets = .zero
        // 设置背景透明
        textView.backgroundColor = .clear
        textView.isOpaque = false

        // 设置段落样式和行距
        let paragraphStyle = NSMutableParagraphStyle()
        paragraphStyle.lineSpacing = lineSpacing
        paragraphStyle.paragraphSpacing = paragraphSpacing

        // 应用段落样式到文本视图的排版属性
        textView.typingAttributes = [
            NSAttributedString.Key.paragraphStyle: paragraphStyle,
            NSAttributedString.Key.font: UIFont.preferredFont(forTextStyle: textStyle)
        ]

        // 返回 UIKit 视图
        return textView
    }

    // UpdateUIView 方法:负责更新 UIKit 视图的状态
    func updateUIView(_ uiView: UITextView, context: Context) {

        // 首先比较 UITextView 当前显示的文本(uiView.text)与 SwiftUI 绑定的文本(text)是否一致
        // 只有在不一致的情况下(或视图处于第一响应者状态)才更新 uiView.text,这避免了不必要的更新和可能的光标位置重置,防止无限循环
        if uiView.text != text {
            uiView.text = text
            // 创建带有段落样式的attributed string
            let paragraphStyle = NSMutableParagraphStyle()
            paragraphStyle.lineSpacing = lineSpacing
            paragraphStyle.paragraphSpacing = paragraphSpacing
        }

        // 检查 UITextView 当前的选择范围(uiView.selectedRange)是否与绑定的选择范围(selection)不同
        // selection.location != NSNotFound 确保选择范围有效(NSNotFound是一个特殊值,表示找不到位置)
        // 只有当这两个条件都满足时,才进行后续的光标位置更新操作
        if uiView.selectedRange != selection && selection.location != NSNotFound {
            // selection.location 表示 SwiftUI 中记录的光标位置
            // uiView.text.utf16.count 获取文本在 UTF-16 编码下的长度
            // min函数确保光标位置不会超过文本长度,防止越界错误。这行代码确保位置参数是安全的,不会导致应用崩溃
            let safeLocation = min(selection.location, uiView.text.utf16.count)
            let safeLength = min(selection.length, max(0, uiView.text.utf16.count - safeLocation))
            let safeRange = NSRange(location: safeLocation, length: safeLength)
            uiView.selectedRange = safeRange
        }

    }

    // MakeCoordinator 方法:仅返回一个 Coordinator 实例
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    // Coordinator 类:采用 UITextViewDelegate 协议
    class Coordinator: NSObject, UITextViewDelegate {

        // 父对象是 TextEditorRepresentable 类型
        var parent: TextEditorRepresentable

        // 初始化方法:设置父对象为该 TextEditorRepresentable 结构
        init(_ parent: TextEditorRepresentable) { self.parent = parent }

        // textViewDidChange 方法:每次更改文本都会调用。这里我们捕获更新的文本,并反过来更新 SwiftUI 的视图
        // 这个方法和前面的 updateUIView 刚好是互逆的
        func textViewDidChange(_ textView: UITextView) {

            // 在 UITextView 光标发生变化(即 textView.selectedRange 变化)时,CustomTextEditor 试图立即修改 parent.selectedRange
            // 这实际上是在 SwiftUI 视图更新 UI 的过程中,直接修改了 @State 或 @Binding
            // SwiftUI 不允许在视图更新过程中直接修改状态,否则会导致不确定的行为
            // SwiftUI 要求所有的 UI 更新必须在主线程异步执行,可以使用 DispatchQueue.main.async 来安全地更新 selectedRange
            DispatchQueue.main.async {
                self.parent.text = textView.text
                self.parent.selection = textView.selectedRange
            }

            // 保持段落样式
            let paragraphStyle = NSMutableParagraphStyle()
            paragraphStyle.lineSpacing = parent.lineSpacing
            paragraphStyle.paragraphSpacing = parent.paragraphSpacing

            textView.typingAttributes[NSAttributedString.Key.paragraphStyle] = paragraphStyle
        }

        func textViewDidChangeSelection(_ textView: UITextView) {
            DispatchQueue.main.async {
                self.parent.selection = textView.selectedRange
            }
        }

    }

}
// ContentView.swift
struct ContentView: View {

    @State private var message = ""
    @State private var textStyle = UIFont.TextStyle.body
    
    var body: some View {
        ZStack(alignment: .topTrailing) {
            TextView(text: $message, textStyle: $textStyle)
                .padding(.horizontal)
            
            Button(action: {
                self.textStyle = (self.textStyle == .body) ? .title1 : .body
            }) {
                Image(systemName: "textformat")
                    .imageScale(.large)
                    .frame(width: 40, height: 40)
                    .foregroundColor(.white)
                    .background(Color.purple)
                    .clipShape(Circle())
                
            }
            .padding()    
        }
    }
}

@FocusState 焦点管理

https://www.appcoda.com.tw/focusstate/?ck_subscriber_id=694320678

<aside> 💡

当我们设置好输入框后,虽然表面上就可以使用了,但是运行时经常都有报错。这是因为我们没有按照规范的做法定义完整输入框。 最主要就是要设置视图上的焦点管理,也就是 @FocusState

</aside>

单个输入框焦点管理

如果视图只有一个输入框,那请使用布尔值类型的 @FocusState 状态参数 + .focused 修饰符来实现。

想要隐藏弹出的键盘,需要给 SwiftUI 一些方法来确定复选框当前是否应该有焦点(是否应该接收来自用户的文本输入)。其次需要添加某种按钮来在用户需要时删除该焦点,这反过来又会导致键盘消失。整个过程是通过 .focused 修饰符和 @FocusState 状态参数实现的。

// 解决第1个问题需要属性包装器 @FocusState ,它与常规 @State 属性完全相同,只不过是专门为处理 UI 中的输入焦点而设计的
struct ContentView: View {

    @State private var name = ""

    // 定义焦点状态属性
    @FocusState private var nameIsFocused: Bool

    var body: some View {
        VStack {
            TextField("Enter your name", text: $name)
		            // 将此修饰符添加到 TextField 后,当文本字段聚焦时,nameIsFocused 是 true,否则为 false
                .focused($nameIsFocused)

						// 解决第二个问题,可以人为地增加一个按钮
            Button("Submit") {
                nameIsFocused = false
            }
        }
    }
}

多个输入框焦点管理

如果视图有多个输入框,那请使用 @FocusState 状态参数来跟踪可选的枚举 case,以检查确定当前聚集的表单字段。

// 例如,我们可能会显示三个文本字段,要求用户提供各种信息,然后在最终信息到达后提交表单:
struct ContentView: View {

    enum Field {
        case firstName
        case lastName
        case emailAddress
    }

    @State private var firstName = ""
    @State private var lastName = ""
    @State private var emailAddress = ""
    @FocusState private var focusedField: Field?

    var body: some View {
        VStack {
            TextField("Enter your first name", text: $firstName)
                .focused($focusedField, equals: .firstName)
                .textContentType(.givenName)
                .submitLabel(.next)

            TextField("Enter your last name", text: $lastName)
                .focused($focusedField, equals: .lastName)
                .textContentType(.familyName)
                .submitLabel(.next)

            TextField("Enter your email address", text: $emailAddress)
                .focused($focusedField, equals: .emailAddress)
                .textContentType(.emailAddress)
                .submitLabel(.join)
        }
        .onSubmit {
            switch focusedField {
            case .firstName:
                focusedField = .lastName
            case .lastName:
                focusedField = .emailAddress
            default:
                print("Creating account…")
            }
        }
    }
}

<aside> 💡 重要提示:您不应尝试对两个不同的表单字段使用相同的焦点绑定。

</aside>

自动获取默认焦点

macOS 上提供了一个 defaultFocus() 修饰符,让我们在视图显示后立即激活一个视图作为用户输入的第一响应者。遗憾的是,它在 iOS 上不存在,但我们可以使用 onAppear() 来解决这个问题。

// iOS 实现方案
struct ContentView: View {
    enum FocusedField {
        case firstName, lastName
    }
    @State private var firstName = ""
    @State private var lastName = ""
    @FocusState private var focusedField: FocusedField?

    var body: some View {
        Form {
            TextField("First name", text: $firstName)
                .focused($focusedField, equals: .firstName)

            TextField("Last name", text: $lastName)
                .focused($focusedField, equals: .lastName)
        }
        .onAppear {
            focusedField = .firstName
        }
    }
}
// macOS 实现方案
struct ContentView: View {
    enum FocusedField {
        case firstName, lastName
    }
    @State private var firstName = ""
    @State private var lastName = ""
    @FocusState private var focusedField: FocusedField?
    var body: some View {
        Form {
            TextField("First name", text: $firstName)
                .focused($focusedField, equals: .firstName)

            TextField("Last name", text: $lastName)
                .focused($focusedField, equals: .lastName)
        }
        .defaultFocus($focusedField, .firstName)
    }
}