如何移动到 SwiftUI 中的下一个 TextField?

How to move to next TextField in SwiftUI?

使用 Swift5.1.2,iOS13.2,Xcode-11.2,

Stackview 中有多个 TextField,我想在用户在第一个 TextField 中键入 x 数量的字符后立即移至下一个 TextField。

使用 ,我实现了识别 TextField 条目何时达到 x 字符数。但是,我不知道如何让 firstResponder 跳转到我的 StackView 中的第二个 TextField。

SwiftUI 有解决这个问题的方法吗?

试试这个:

import SwiftUI

struct ResponderTextField: UIViewRepresentable {

    typealias TheUIView = UITextField
    var isFirstResponder: Bool
    var configuration = { (view: TheUIView) in }

    func makeUIView(context: UIViewRepresentableContext<Self>) -> TheUIView { TheUIView() }
    func updateUIView(_ uiView: TheUIView, context: UIViewRepresentableContext<Self>) {
        _ = isFirstResponder ? uiView.becomeFirstResponder() : uiView.resignFirstResponder()
        configuration(uiView)
    }
}


struct ContentView: View {
    @State private var entry = ""
    @State private var entry2 = ""

    let characterLimit = 6

    var body: some View {
        VStack {
            TextField("hallo", text: $entry)
                .disabled(entry.count > (characterLimit - 1))

            ResponderTextField(isFirstResponder: entry.count > (characterLimit - 1)) { uiView in
                uiView.placeholder = "2nd textField"
            }
        }
    }
}

我能够通过 Introspect 库完成这项工作。 https://github.com/siteline/SwiftUI-Introspect:

              @State private var passcode = ""

              HStack {
                  TextField("", text: self.$passcode)
                    .introspectTextField { textField in
                      if self.passcode.count >= 1 {
                        textField.resignFirstResponder()
                      } else if self.passcode.count < 1 {
                        textField.becomeFirstResponder()
                      }
                  }
                  TextField("", text: self.$passcode)
                    .introspectTextField { textField in
                      if self.passcode.count >= 2
                        textField.resignFirstResponder()
                      } else if self.passcode.count < 2 {
                        textField.becomeFirstResponder()
                      }
                  }
              }

我可能因为尝试复制和粘贴我的代码而搞砸了实现,但您了解了它的工作原理。

我正在使用 UITextFieldUIViewRepresentable 来实现这一点。

定义每个文本字段的 tag 并声明一个布尔值列表,该列表具有相同数量的可用文本字段以聚焦 return 键 fieldFocus,这将跟踪根据当前 index/tag.

接下来要关注哪个文本字段

用法:

import SwiftUI

struct Sample: View {
    @State var firstName: String = ""
    @State var lastName: String = ""
    
    @State var fieldFocus = [false, false]
    
    var body: some View {
        VStack {
            KitTextField (
                label: "First name",
                text: $firstName,
                focusable: $fieldFocus,
                returnKeyType: .next,
                tag: 0
            )
            .padding()
            .frame(height: 48)
            
            KitTextField (
                label: "Last name",
                text: $lastName,
                focusable: $fieldFocus,
                returnKeyType: .done,
                tag: 1
            )
            .padding()
            .frame(height: 48)
        }
    }
}

UITextFieldUIViewRepresentable:

import SwiftUI

struct KitTextField: UIViewRepresentable {
    let label: String
    @Binding var text: String
    
    var focusable: Binding<[Bool]>? = nil
    var isSecureTextEntry: Binding<Bool>? = nil
    
    var returnKeyType: UIReturnKeyType = .default
    var autocapitalizationType: UITextAutocapitalizationType = .none
    var keyboardType: UIKeyboardType = .default
    var textContentType: UITextContentType? = nil
    
    var tag: Int? = nil
    var inputAccessoryView: UIToolbar? = nil
    
    var onCommit: (() -> Void)? = nil
    
    func makeUIView(context: Context) -> UITextField {
        let textField = UITextField(frame: .zero)
        textField.delegate = context.coordinator
        textField.placeholder = label
        
        textField.returnKeyType = returnKeyType
        textField.autocapitalizationType = autocapitalizationType
        textField.keyboardType = keyboardType
        textField.isSecureTextEntry = isSecureTextEntry?.wrappedValue ?? false
        textField.textContentType = textContentType
        textField.textAlignment = .left
        
        if let tag = tag {
            textField.tag = tag
        }
        
        textField.inputAccessoryView = inputAccessoryView
        textField.addTarget(context.coordinator, action: #selector(Coordinator.textFieldDidChange(_:)), for: .editingChanged)
        
        textField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
        
        return textField
    }
    
    func updateUIView(_ uiView: UITextField, context: Context) {
        uiView.text = text
        uiView.isSecureTextEntry = isSecureTextEntry?.wrappedValue ?? false
        
        if let focusable = focusable?.wrappedValue {
            var resignResponder = true
            
            for (index, focused) in focusable.enumerated() {
                if uiView.tag == index && focused {
                    uiView.becomeFirstResponder()
                    resignResponder = false
                    break
                }
            }
            
            if resignResponder {
                uiView.resignFirstResponder()
            }
        }
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    final class Coordinator: NSObject, UITextFieldDelegate {
        let control: KitTextField
        
        init(_ control: KitTextField) {
            self.control = control
        }
        
        func textFieldDidBeginEditing(_ textField: UITextField) {
            guard var focusable = control.focusable?.wrappedValue else { return }
            
            for i in 0...(focusable.count - 1) {
                focusable[i] = (textField.tag == i)
            }
            
            control.focusable?.wrappedValue = focusable
        }
        
        func textFieldShouldReturn(_ textField: UITextField) -> Bool {
            guard var focusable = control.focusable?.wrappedValue else {
                textField.resignFirstResponder()
                return true
            }
            
            for i in 0...(focusable.count - 1) {
                focusable[i] = (textField.tag + 1 == i)
            }
            
            control.focusable?.wrappedValue = focusable
            
            if textField.tag == focusable.count - 1 {
                textField.resignFirstResponder()
            }
            
            return true
        }
        
        func textFieldDidEndEditing(_ textField: UITextField) {
            control.onCommit?()
        }
        
        @objc func textFieldDidChange(_ textField: UITextField) {
            control.text = textField.text ?? ""
        }
    }
}

iOS 15

今年,Apple 引入了一个新修饰符以及一个名为 @FocusState 的新包装器,用于控制键盘和聚焦键盘的状态('aka' firstResponder)。

以下是如何迭代文本字段的示例:

此外,您可以查看 以了解如何创建文本字段第一响应程序或退出它以隐藏键盘并了解有关如何操作的更多信息将此枚举绑定到文本字段。

iOS 15

在 iOS 15 中,我们现在可以使用 @FocusState 来控制应聚焦哪个字段。

这是一个演示:

struct ContentView: View {
    @State private var street: String = ""
    @State private var city: String = ""
    @State private var country: String = ""

    @FocusState private var focusedField: Field?

    var body: some View {
        NavigationView {
            VStack {
                TextField("Street", text: $street)
                    .focused($focusedField, equals: .street)
                TextField("City", text: $city)
                    .focused($focusedField, equals: .city)
                TextField("Country", text: $country)
                    .focused($focusedField, equals: .country)
            }
            .toolbar {
                ToolbarItem(placement: .keyboard) {
                    Button(action: focusPreviousField) {
                        Image(systemName: "chevron.up")
                    }
                    .disabled(!canFocusPreviousField()) // remove this to loop through fields
                }
                ToolbarItem(placement: .keyboard) {
                    Button(action: focusNextField) {
                        Image(systemName: "chevron.down")
                    }
                    .disabled(!canFocusNextField()) // remove this to loop through fields
                }
            }
        }
    }
}
extension ContentView {
    private enum Field: Int, CaseIterable {
        case street, city, country
    }
    
    private func focusPreviousField() {
        focusedField = focusedField.map {
            Field(rawValue: [=11=].rawValue - 1) ?? .country
        }
    }

    private func focusNextField() {
        focusedField = focusedField.map {
            Field(rawValue: [=11=].rawValue + 1) ?? .street
        }
    }
    
    private func canFocusPreviousField() -> Bool {
        guard let currentFocusedField = focusedField else {
            return false
        }
        return currentFocusedField.rawValue > 0
    }

    private func canFocusNextField() -> Bool {
        guard let currentFocusedField = focusedField else {
            return false
        }
        return currentFocusedField.rawValue < Field.allCases.count - 1
    }
}

注意:截至 Xcode 13 beta 1 @FocusStateForm/List 中不起作用。这应该会在下一个版本中修复。

iOS 15+

使用@FocusState

之前 iOS 15

我已经接受了@Philip Borbon 的回答并稍微清理了一下。我删除了很多自定义项并保持最低限度,以便更容易看到需要的内容。

struct CustomTextfield: UIViewRepresentable {
    let label: String
    @Binding var text: String
    
    var focusable: Binding<[Bool]>? = nil
    
    var returnKeyType: UIReturnKeyType = .default
    
    var tag: Int? = nil
    
    var onCommit: (() -> Void)? = nil
    
    func makeUIView(context: Context) -> UITextField {
        let textField = UITextField(frame: .zero)
        textField.placeholder = label
        textField.delegate = context.coordinator
        
        textField.returnKeyType = returnKeyType
        
        if let tag = tag {
            textField.tag = tag
        }
        
        return textField
    }
    
    func updateUIView(_ uiView: UITextField, context: Context) {
        uiView.text = text
        
        if let focusable = focusable?.wrappedValue {
            var resignResponder = true
            
            for (index, focused) in focusable.enumerated() {
                if uiView.tag == index && focused {
                    uiView.becomeFirstResponder()
                    resignResponder = false
                    break
                }
            }
            
            if resignResponder {
                uiView.resignFirstResponder()
            }
        }
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    final class Coordinator: NSObject, UITextFieldDelegate {
        let parent: CustomTextfield
        
        init(_ parent: CustomTextfield) {
            self.parent = parent
        }
        
        func textFieldDidBeginEditing(_ textField: UITextField) {
            guard var focusable = parent.focusable?.wrappedValue else { return }
            
            for i in 0...(focusable.count - 1) {
                focusable[i] = (textField.tag == i)
            }
            parent.focusable?.wrappedValue = focusable
        }
        
        func textFieldShouldReturn(_ textField: UITextField) -> Bool {
            guard var focusable = parent.focusable?.wrappedValue else {
                textField.resignFirstResponder()
                return true
            }
            
            for i in 0...(focusable.count - 1) {
                focusable[i] = (textField.tag + 1 == i)
            }
            
            parent.focusable?.wrappedValue = focusable
            
            if textField.tag == focusable.count - 1 {
                textField.resignFirstResponder()
            }
            
            return true
        }
        
        @objc func textFieldDidChange(_ textField: UITextField) {
            parent.text = textField.text ?? ""
        }
    }
}

如果您可以使用 iOS 15,Mojtaba 提出的解决方案非常好。由于大多数项目必须支持旧版本的 iOS,因此它不起作用。但是,如果您使用的是 iOS 13 或 iOS 14,则可以使用提供该功能的 Focuser 库。

您可以从 Github 下载示例项目以查看示例。然而 API 被建模为在 iOS 15.

中工作

我终于相信 iOS 15 有一个真正的 SwiftUI 解决方案可以解决这个问题。

, and wrote and article about it 因为找不到。

基本上你可以创建一些东西来完成这个:

  • 焦点对象:观察到的可识别对象或焦点数组索引 int 变量
  • 可聚焦对象数组:与您要作为第一响应者迭代的文本字段关联的可识别对象数组
  • TextFieldWrapper:用于管理每个 TextField 的 FocusState 和更新 Focus 对象的对象(参见第一个项目符号)

然后将闭包或函数引用传递给 TextField Wrapper 对象,以允许它更新数组中的焦点对象。我会使用某种视图模型,也许是 FocusStateViewModel。您可以制定更复杂的解决方案来满足您的需求 from this gist

或者,这里是解决方案的最小复制:

import SwiftUI

struct MyObject: Identifiable, Equatable {
    var id: String
    public var value: String
    init(name: String, value: String) {
        self.id = name
        self.value = value
    }
}

struct ContentView: View {

    @State var myObjects: [MyObject] = [
        MyObject(name: "aa", value: "1"),
        MyObject(name: "bb", value: "2"),
        MyObject(name: "cc", value: "3"),
        MyObject(name: "dd", value: "4")
    ]
    @State var focus: MyObject?

    var body: some View {
        ScrollView(.vertical) {
            VStack {
                Text("Header")
                ForEach(self.myObjects) { obj in
                    Divider()
                    FocusField(displayObject: obj, focus: $focus, nextFocus: {
                        guard let index = self.myObjects.firstIndex(of: [=10=]) else {
                            return
                        }
                        self.focus = myObjects.indices.contains(index + 1) ? myObjects[index + 1] : nil
                    })
                }
                Divider()
                Text("Footer")
            }
        }
    }
}

struct FocusField: View {

    @State var displayObject: MyObject
    @FocusState var isFocused: Bool
    @Binding var focus: MyObject?
    var nextFocus: (MyObject) -> Void

    var body: some View {
    TextField("Test", text: $displayObject.value)
            .onChange(of: focus, perform: { newValue in
                self.isFocused = newValue == displayObject
            })
            .focused(self.$isFocused)
            .submitLabel(.next)
            .onSubmit {
                self.nextFocus(displayObject)
            }
    }
}

使用 iOS 15+ @FocusState - 通用解决方案

用法示例:

@FocusState private var focusedField: Field?
enum Field: Int, Hashable {
   case name
   case country
   case city
}

var body: some View {
    TextField(text: $name)
        .focused($focusedField, equals: .name)
        .onSubmit { self.focusNextField($focusedField) }
// ...

代码:

extension View {
    /// Focuses next field in sequence, from the given `FocusState`.
    /// Requires a currently active focus state and a next field available in the sequence.
    ///
    /// Example usage:
    /// ```
    /// .onSubmit { self.focusNextField($focusedField) }
    /// ```
    /// Given that `focusField` is an enum that represents the focusable fields. For example:
    /// ```
    /// @FocusState private var focusedField: Field?
    /// enum Field: Int, Hashable {
    ///    case name
    ///    case country
    ///    case city
    /// }
    /// ```
    func focusNextField<F: RawRepresentable>(_ field: FocusState<F?>.Binding) where F.RawValue == Int {
        guard let currentValue = field.wrappedValue else { return }
        let nextValue = currentValue.rawValue + 1
        if let newValue = F.init(rawValue: nextValue) {
            field.wrappedValue = newValue
        }
    }

    /// Focuses previous field in sequence, from the given `FocusState`.
    /// Requires a currently active focus state and a previous field available in the sequence.
    ///
    /// Example usage:
    /// ```
    /// .onSubmit { self.focusNextField($focusedField) }
    /// ```
    /// Given that `focusField` is an enum that represents the focusable fields. For example:
    /// ```
    /// @FocusState private var focusedField: Field?
    /// enum Field: Int, Hashable {
    ///    case name
    ///    case country
    ///    case city
    /// }
    /// ```
    func focusPreviousField<F: RawRepresentable>(_ field: FocusState<F?>.Binding) where F.RawValue == Int {
        guard let currentValue = field.wrappedValue else { return }
        let nextValue = currentValue.rawValue - 1
        if let newValue = F.init(rawValue: nextValue) {
            field.wrappedValue = newValue
        }
    }
}