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

21
使用Swift5.1.2, iOS13.2, Xcode-11.2,当在StackView中有多个TextFields时,我想要在用户在第一个TextField中输入x个字符后立即跳转到下一个TextField。
通过这个链接,我能够识别出TextField输入已经达到了x个字符。但是,我不知道如何使第一响应者跳转到StackView内的第二个TextField。
在SwiftUI中有没有解决方案?

我没有用SwiftUI写完整的答案,但是我会首先使用stackView.arrangedSubviews.firstIndex(of: view)获取StackView中当前firstResponder的索引。然后,获取found index + 1的arrangedSubview,并使用becomeFirstResponder()将其设置为firstResponder。 - Jeroen
在 iOS 15 中,我们现在可以使用 @FocusState 控制哪个字段应该被聚焦 - 参见 此答案 - pawello2222
10个回答

9

我使用UITextFieldUIViewRepresentable来实现这个功能。

为每个文本字段定义标签tag,并声明一个布尔列表fieldFocus,其数量与可用文本字段相同以便在Return键按下时跟踪要聚焦的文本字段,该列表将根据当前索引/标签确定下一个要聚焦的文本字段。

用法:

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)
        }
    }
}

UIViewRepresentable 中的 UITextField:

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 ?? ""
        }
    }
}

enter image description here


这真的很酷,你能不能做一个更简单的版本?我正在尝试实现你的解决方案,但我不太确定所需的绑定。甚至添加一些文档也会很好。 - xTwisteDx
使其移动到下一个字段所需的参数为“label”、“text”、“focusable”和“tag”。我只有其他参数来公开一些UITextField属性以自定义它或根据我的使用情况省略或公开您需要或不需要的属性。 - Philip Borbon
@xTwisteDx,我已经发布了一个答案,在那里我让这个解决方案变得更简单了。 - AlphaWulf

9

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
        }
    }
}


8

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 ?? ""
        }
    }
}

1
我在iOS 15中成功实现了这一点,而不需要使用UIViewRepresentable。https://dev59.com/lFMH5IYBdhLWcg3wsBUS#70293614 - Michael Ellis
1
更新了答案,包括 iOS 15 的解决方案。 - AlphaWulf
1
看起来你删掉了一点太多了。textField.addTarget(context.coordinator, action: #selector(Coordinator.textFieldDidChange(_:)), for: .editingChanged) 仍然是必需的(它不是 UITextFieldDelegate 的一部分)。 - Tobi Schweiger
这个很好用,但当下一个文本框被键盘隐藏时,我会收到“AttributeGraph: cycle detected through attribute”错误,并且下一个文本框无法聚焦。有什么办法可以解决这个问题吗? - Wonton
使用此答案解决了我的AttributeGraph错误:https://dev59.com/ZK32oIgBc1ULPQZFre1t#70238866 - Wonton

6

iOS 15+

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

这是一个演示:

enter image description here

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: $0.rawValue - 1) ?? .country
        }
    }

    private func focusNextField() {
        focusedField = focusedField.map {
            Field(rawValue: $0.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
    }
}

5

iOS 15

今年,苹果推出了一个名为@FocusState的新修饰符以及一个新的包装器,用于控制键盘和聚焦的状态(也称为firstResponder)。

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

示例

此外,您可以查看这个答案,了解如何使textField成为first responder或resign它以隐藏键盘以及有关如何将此枚举绑定到textFields的更多信息。


怎样才能自动获取那些上下箭头呢? - Peter Lapisu
怎样才能自动获取那些上下箭头呢? - undefined
简单的.toolbar项目 - Mojtaba Hosseini
简单的.toolbar项目 - undefined

5

试试这个:

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"
            }
        }
    }
}

1
至少它有一个赞;是的,在我编写时它是有效的。 - Chris
@Learn2Code,它有效。只需将他/她的代码插入视图中并自行查看即可。但是它只能从第一个文本字段移动到第二个,不知道如何移动第三个、第四个等等。 - GrandSteph
不用在意第三个、第四个……它很容易修改就能让它工作 :) - GrandSteph
@GrandSteph 你是如何修改它以适应第三和第四个文本字段的?我需要有6个... - Jack
1
@Jack 我最终没有使用这段代码。相反,我使用了类似于这个的东西 this SATexfield ,因为我发现它更容易、更多功能并且更清晰。 - GrandSteph

2
Mojtaba提出的解决方案非常好,如果您可以使用iOS 15进行工作。由于大多数项目必须支持旧版本的iOS,因此它不起作用。但是,如果您正在使用iOS 13或iOS 14,则可以使用Focuser库来实现完全相同的功能。enter image description here 您可以从Github下载示例项目以查看示例。但是API的模型要求与iOS 15相同。

2
借鉴Michael Ellis的回答,你可以通过将@State的focus变量改为@FocusState来消除isFocused @FocusState。
    @FocusState 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: $0) else {
                            return
                        }
                        self.focus = myObjects.indices.contains(index + 1) ? myObjects[index + 1] : nil
                    })
                }
                Divider()
                Text("Footer")
            }
        }
    }
}

struct FocusField: View {
    @State var displayObject: MyObject
    @Binding var focus: FocusState<MyObject?>.Binding
    var nextFocus: (MyObject) -> Void

    var body: some View {
    TextField("Test", text: $displayObject.value)
            .focused(self.focus, equals: displayObject)
            .submitLabel(.next)
            .onSubmit {
                self.nextFocus(displayObject)
            }
    }
}

1

我相信在iOS 15中,终于有了一个真正的SwiftUI解决方案来解决这个问题。

遇到过这个问题,并且写了一篇文章关于它,因为我找不到一个解决方法。

基本上,你可以创建几个东西来完成这个任务:

  • 焦点对象:用于观察的可识别对象或数组索引整数变量,用于定义你的焦点
  • 可聚焦对象数组:与你想迭代作为第一响应者的文本字段相关联的可识别对象数组
  • TextFieldWrapper:用于管理每个TextField的FocusState以及更新焦点对象(见第一个bullet)

然后,您可以将闭包或函数引用传递给TextField Wrapper对象,以允许它从数组中更新Focused Object。我建议使用某种视图模型,例如FocusStateViewModel。您可以创建一个更复杂的解决方案来满足您的需求从这个gist中获取

enter image description here

或者,这里是解决方案的最小化重现:

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: $0) 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)
            }
    }
}

0
我使用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()
                      }
                  }
              }

我可能在尝试复制和粘贴代码时搞砸了实现,但你可以理解它的工作原理。


这不会按预期工作,因为它会在用户输入时跳到下一个字段。这很令人困惑。 - Jonas Deichelmann

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接