19

Using Swift5.1.2, iOS13.2, Xcode-11.2,

Having several TextFields in a Stackview, I would like to move to the next TextField as soon as the user types x-amount of characters into the first TextField.

With this link, I achieve to recognise when a TextField entry has reached x-amount of characters. However, I do not know how to make the firstResponder jump to a second TextField inside my StackView.

Is there a solution to this with SwiftUI ?

iKK
  • 6,394
  • 10
  • 58
  • 131
  • I don't have a complete answer written in SwiftUI, but I would first get the index of the current firstResponder in the StackView using `stackView.arrangedSubviews.firstIndex(of: view)`. Then, get the arrangedSubview of the `found index + 1`, and make that firstResponder using `becomeFirstResponder()`. – Jeroen Dec 18 '19 at 12:19
  • In iOS 15 we can now use `@FocusState` to control which field should be focused - see [this answer](https://stackoverflow.com/a/68010971/8697793). – pawello2222 Jun 16 '21 at 23:01

10 Answers10

9

I'm using UITextField and UIViewRepresentable to achieve this.

Define tag of each text field and declare a list of booleans with same count of available text fields to be focused of return key, fieldFocus, that will keep track of which textfield to focus next base on the current index/tag.

Usage:

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

UITextField in UIViewRepresentable:

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

Philip Borbon
  • 707
  • 1
  • 11
  • 21
  • this is really cool, can you make a simpler version of it? I'm attempting to implement your solution but I'm not really sure of the bindings that are required. Even adding some documentation would be nice. – xTwisteDx Apr 08 '21 at 18:47
  • The required parameters to make it move to next fields are `label`, `text`, `focusable`, and `tag`. I just have the other parameters to expose some the `UITextField` properties to customize it or base on my usage, you can omit or expose properties you need or don't need. – Philip Borbon Apr 09 '21 at 01:57
  • @xTwisteDx I've posted an answer where I made this solution a little simpler. – AlphaWulf Jun 19 '21 at 05:01
8

iOS 15+

Use @FocusState

Before iOS 15

I've taken @Philip Borbon answer and cleaned it up a little bit. I've removed a lot of the customization and kept in the bare minimum to make it easier to see what's required.

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 ?? ""
        }
    }
}
AlphaWulf
  • 354
  • 5
  • 11
  • 1
    I was able to achieve this without UIViewRepresentable in iOS 15 https://stackoverflow.com/a/70293614/5632572 – Michael Ellis Dec 09 '21 at 16:46
  • 1
    Updated the answer to include iOS 15 solution. – AlphaWulf Dec 27 '21 at 19:31
  • 1
    Looks like you removed a little bit too much. ` textField.addTarget(context.coordinator, action: #selector(Coordinator.textFieldDidChange(_:)), for: .editingChanged)` is still needed (it's not part of `UITextFieldDelegate`). – Tobi Schweiger Sep 13 '22 at 21:22
  • This works very well but when the next textfield is hidden by the keyboard I got the "AttributeGraph: cycle detected through attribute" error and the next textfield is not focused. Any way to fix this? – Wonton Sep 29 '22 at 15:43
  • Fixed my AttributeGraph error with this answer: Fixed with this answer: https://stackoverflow.com/a/70238866/1639825 – Wonton Sep 29 '22 at 15:51
8

Using iOS 15+ @FocusState - Generic solution

Example usage:

@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) }
// ...

Code:

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

Zsolt Molnár
  • 305
  • 2
  • 8
5

try this:

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"
            }
        }
    }
}
Chris
  • 7,579
  • 3
  • 18
  • 38
  • 1
    at least it has a vote up ;) and yes, it worked when i wrote it – Chris Mar 15 '20 at 08:35
  • @Learn2Code, it works. Just plug his / her code into a view and see by yourself. However it will move from first to second textfield but don't see how to move the 3rd, 4th ... – GrandSteph May 25 '20 at 08:41
  • never mind the 3rd, 4th ... It's easily modifiable to make it work :) – GrandSteph May 25 '20 at 08:49
  • @GrandSteph How did you modify it to accommodate 3rd and 4th text fields? I need to have 6... – Jack Aug 18 '20 at 19:18
  • 1
    @Jack I ended up not using this code. Instead I used something similar to [this](https://github.com/valvoline/SATextField) SATexfield as I found that it was easier and more versatile and cleaner – GrandSteph Aug 19 '20 at 07:46
5

iOS 15

This year, apple introduced a new modifier alongside with a new wrapper called @FocusState that controls the state of the keyboard and the focused keyboard ('aka' firstResponder).

Here is the example of how you can iterate over textFields:

Demo

Also, you can take a look at this answer to see how you can make a textField first responder or resign it to hide the keyboard and learn more about how to bind this enum to the textFields.

Mojtaba Hosseini
  • 95,414
  • 31
  • 268
  • 278
4

iOS 15+

In iOS 15 we can now use @FocusState to control which field should be focused.

Here is a demo:

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
    }
}
pawello2222
  • 46,897
  • 22
  • 145
  • 209
2

Solution proposed by Mojtaba is great if you can work with iOS 15. Since most projects have to support old versions of iOS it doesn't work. However, if you're using iOS 13 or iOS 14 you can use Focuser library which offers exactly that.enter image description here

You can download example project from Github to see an example. However API is modelled to work as in iOS 15.

Markon
  • 741
  • 8
  • 23
1

I believe finally in iOS 15 there is a real SwiftUI solution to this issue.

I had this problem, and wrote and article about it since I couldn't find one.

Basically you can create a few things to accomplish this:

  • Focus Object: An observed Identifiable object or array index int variable for your focus
  • Focusable Objects Array: An array of Identifiable objects associated with the TextFields you want to iterate over as First Responders
  • TextFieldWrapper: An object to manage each TextField's FocusState and to update the Focus Object(see first bullet)

Then you pass a closure or func reference to a TextField Wrapper object to allow it to update the Focused Object from your array. I would use a View Model of some kind, a FocusStateViewModel maybe. You can make a more complex solution to fit your needs from this gist

enter image description here

Or, here is a minimal reproduction of the solution:

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)
            }
    }
}
Michael Ellis
  • 181
  • 3
  • 15
1

Building on the answer from Michael Ellis, you can eliminate the isFocused @FocusState in favor of changing the @State focus var to be a @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)
            }
    }
}
Adrian Harris Crowne
  • 1,335
  • 2
  • 12
  • 11
0

I was able to get this done with Introspect library. 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()
                      }
                  }
              }

I may have messed up the implementation from trying to copy and paste my code but you get the gist of how it would work.

BokuWaTaka
  • 168
  • 6