若要在可选值变为 nil 后仍能编辑数字字段,请将可编辑文本与数字分开保存。先检查解析、禁用状态和焦点;如果应用需要明确的恢复行为,再使用由字符串支持的草稿。

本指南介绍你开发的 macOS 应用中的数字字段:输入一个虚构的 Tile count,将其清空,然后再输入另一个数字。目标是在保留有意义的空值的同时,允许继续编辑。

为什么可选数字会变为 nil?

Apple 为可选值专门提供了一个 SwiftUI 初始化器:TextField(value:format:prompt:label:)。其文档说明,当格式样式无法解析输入时,会将绑定设为 nil。因此,转换失败是此 API 的预期状态。Apple 并未将这种状态描述为禁用字段。这个可选格式样式初始化器要求 macOS 12 或更高版本。Apple 的可选值 TextField 初始化器

检查应用实际使用的是哪个初始化器。较旧的 formatter: 初始化器有不同的文档化失败行为:如果转换失败,它会让绑定值保持不变。将 formatter: 替换为 format: 会改变这一约定,因此应将其视为有意的行为变更,而不是通用修复。Apple 的基于 formatter 的 TextField 初始化器

根本的设计问题在于,可选数字无法表示所有编辑状态。空字段、只输入了一部分的减号以及无效字符串,都可能没有可用的整数,但它们是不同的文本草稿。单独保存草稿因此很有用:字段可以准确保留用户正在编辑的内容,而应用可以决定何时接受数值。

更改字段之前先诊断故障

在你自己的应用窗口中按以下顺序检查。这样可以区分转换问题和交互问题,而无需假设某种特定的 macOS 实现缺陷。

  1. 检查文本是否真的发生变化。 点击字段,选中其中的内容,然后输入 24。如果字符发生变化,但数字模型没有变化,请检查转换和提交处理。
  2. 检查交互条件。 搜索字段及其容器,查找 .disabled(tileCount == nil) 之类的条件。该条件会阻止恢复空值所需的编辑操作。
  3. 检查字段是否仍然存在。 如果视图只在 if let 分支中创建编辑器,将值设为 nil 可能会移除编辑器。无论可选数字是什么状态,都应让草稿和编辑器保持可用。
  4. 单独检查焦点。 如果点击字段后可以继续输入,请通过相应的公开 API 恢复焦点。
  5. 简化示例。 暂时移除自定义绑定转换和模型同步。在一个小视图中重现从 12 到空值再到 24 的变化。

禁用的祖先视图也会产生影响:Apple 文档说明,容器的 .disabled(true) 会覆盖子视图上的 .disabled(false)。应修正祖先视图的条件,而不是反复只启用字段。Apple 的 disabled(_:) 文档

也要检查自定义绑定 setter。如果 setter 在现有数字为 nil 时总是提前返回,就无法接受替代数字。同样,将每次转换失败都映射为 0,会丢失“无值”和实际的零之间的区别。这些属于应用逻辑决策,改变焦点无法修复它们。

保留可编辑草稿并显式提交数字

由字符串支持的字段让你能够分别控制三种状态:当前文本、已接受的可选数字和验证消息。Apple 文档说明,绑定到字符串的 TextField 会在编辑过程中持续更新其绑定。因此,它适合用来保留尚未完成的输入。Apple 的 TextField 概述

下面的完整示例采用以下策略:空输入表示 nil;有效的十进制整数会替换已接受的数字;无效输入仍会显示,且已接受的数字保持不变。Clear 按钮会清空两种状态并请求焦点,以便输入另一个数字。

在 Xcode 中创建一个 macOS SwiftUI App 项目,部署目标设为 macOS 12 或更高版本。将生成的 app 和 content-view 声明替换为这个单文件实现,并确保目标中只保留一个 @main 声明。示例只使用自己的窗口和虚构标签。研究环境中没有 Swift 和 macOS SDK,因此检查了这段程序,但没有编译或运行。

import SwiftUI

@main
struct NumericEditingDemoApp: App {
    var body: some Scene {
        WindowGroup {
            NumericEditingView()
        }
    }
}

struct NumericEditingView: View {
    @State private var tileCount: Int? = 12
    @State private var draft = "12"
    @State private var message: String? = nil
    @FocusState private var fieldIsFocused: Bool

    private var acceptedDescription: String {
        if let value = tileCount {
            return String(value)
        }
        return "None"
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            TextField("Tile count", text: $draft)
                .textFieldStyle(.roundedBorder)
                .focused($fieldIsFocused)
                .onSubmit {
                    applyDraft()
                }

            HStack {
                Button("Apply") {
                    applyDraft()
                }

                Button("Clear") {
                    tileCount = nil
                    draft = ""
                    message = nil
                    fieldIsFocused = true
                }

                Button("Edit") {
                    fieldIsFocused = true
                }
            }

            Text("Accepted count: \(acceptedDescription)")

            if let message = message {
                Text(message)
                    .foregroundColor(.secondary)
            }
        }
        .padding(20)
        .frame(width: 380)
    }

    private func applyDraft() {
        if draft.isEmpty {
            tileCount = nil
            message = nil
            return
        }

        guard let parsed = Int(draft, radix: 10) else {
            message = "Enter a whole number without spaces, or clear the field."
            fieldIsFocused = true
            return
        }

        tileCount = parsed
        draft = String(parsed)
        message = nil
    }
}

字段始终绑定到 draft,因此将 tileCount 设为 nil 不会移除其可编辑文本状态。只有用户点击 Apply 或按 Return 时才会进行转换。提交无效内容后,消息会一直保留到下次提交或点击 Clear;草稿仍可用于修正。

guard 会阻止转换失败时替换已接受的数字。成功提交时会通过 String(parsed) 规范化输入形式:例如,输入 +024 后会得到 24。这是示例明确采用的策略。如果需要保留提交时的输入形式,请移除这条规范化赋值。

明确选择数字语法

本示例接受带符号、无分组的十进制整数。它会拒绝空格、小数和分组分隔符。Swift 文档说明,Int(_:radix:) 在输入无效或数值超出整数类型可表示范围时会返回 nil,因此,粘贴超大数字也会进入验证流程。Swift 的 Int 字符串初始化器

如果字段需要本地化数字格式,请保留相同的草稿与提交结构,但将转换替换为适当的 Foundation 解析策略。ParseableFormatStyle 提供 parseStrategy,而解析策略的 parse(_:) 方法会执行转换,并且可能抛出错误。处理失败时应保留草稿。选择相匹配的格式化和解析约定,然后测试预期使用的区域设置。Apple 的 ParseableFormatStyle 文档、Apple 的 ParseStrategy 文档

如果应用的其他部分可能替换已接受的数字,请定义这种更新会如何影响未完成的草稿。例如,明确的 Load value 操作可以同时更新两种状态。编辑过程中避免持续用已接受的数字重建草稿,否则会丢弃此设计所要保留的未完成文本。

通过 SwiftUI 或 AppKit 恢复焦点

在 SwiftUI 中,将 .focused($fieldIsFocused) 附加到编辑器,并像示例那样从本地操作设置 fieldIsFocused = true。Apple 文档说明,赋予焦点状态一个值会将焦点移至关联视图。不同字段应使用不同的焦点绑定;多个字段使用相同的焦点值会造成歧义。Apple 的 FocusState 文档

焦点决定编辑会转到哪里。它不会改变解析器、重新启用已禁用的祖先视图,也不会重新创建被条件视图逻辑移除的编辑器。添加焦点恢复之前,请先解决这些情况。

对于应用拥有的 AppKit 字段,请检查 NSTextField.isEditable。Apple 将此属性定义为控制用户能否编辑字段值。如果你自己的代码错误地将其设为 false,请将它恢复为 true,同时保留任何有意设置的应用限制。Apple 的 NSTextField.isEditable 文档

显式移动 AppKit 焦点时,请检查 acceptsFirstResponder,并调用所属窗口的 makeFirstResponder(_:)。需要谨慎处理其返回结果:false 可能表示当前响应者拒绝放弃焦点;而 true 也可能出现在拟设为响应者的对象拒绝后、窗口成为第一响应者的情况下。请验证最终的编辑状态。要选中整个字段以便替换,文档指定的操作是 selectText(_:);在字段属于某个窗口的视图层级之前调用它不会产生效果。Apple 的 makeFirstResponder(_:) 文档、Apple 的 selectText(_:) 文档

遵循字段编辑器的生命周期

AppKit 的 currentEditor() 会返回控件当前的字段编辑器;如果没有,则返回 nil。Apple 将该编辑器描述为由窗口内多个控件共享、并在需要时创建的 NSTextView。因此,在活动编辑会话之外出现 nil 编辑器,并不能证明数字输入发生故障。Apple 的 currentEditor() 文档

在文本系统层面,字段编辑器会将 Tab、Shift-Tab 和 Return 视为结束编辑并可能更改第一响应者的信号。不要将 isFieldEditor 当作数字转换修复:该属性控制的是编辑行为,而非可选数字解析。仅对应用拥有的控件进行这些 AppKit 和 TextKit 检查。Apple 的 NSTextView.isFieldEditor 文档

验证清空和重新输入都能正常工作

在应用支持的每个 macOS 版本上,针对完整示例运行以下手动流程。这些是根据示例逻辑推导出的预期结果,并非实际执行测试后报告的结果。

操作 预期已接受的值 预期编辑行为
启动 12 草稿包含 12
点击 Clear nil,显示为 None 草稿为空;已请求焦点
输入 24,然后点击 Apply 24 草稿包含 24
替换为 -,然后按 Return 前一个已接受的值 草稿仍为 -;显示消息
替换为 -8,然后按 Return -8 消息清除
输入 0,然后点击 Apply 0 零与无值保持区别
输入 2.5,然后点击 Apply 前一个已接受的值 小数文本保留,供用户修正
清空字段,然后点击 Apply nil 字段仍可用,可以继续编辑

还要测试粘贴一个大于 Int.max 的整数、点击其他位置后再返回,以及反复清空字段。在集成到应用后,还要包含任何会在其他位置更改模型的操作。这些检查分别覆盖转换失败、焦点恢复和草稿同步。

恢复条件很明确:已接受的数字变为 nil 后,编辑器仍存在且可交互,之后有效的提交能够提供新数字。将草稿独立保存,就能明确保证这一行为,而无需依赖 SwiftUI 底层控件未公开说明的变化。

来源