SwiftUI custom TextField. Part 1: Basics


Greetings, traveler!

SwiftUI is becoming an increasingly popular framework. However, its components currently have certain limitations. At the same time, UIKit offers much greater flexibility. How can we take advantage of UIKit inside SwiftUI View? Let’s check this out with an example of a combination of SwiftUI and UITextField.

The UIViewRepresentable protocol is a key tool in combining SwiftUI and UIKit. It acts as a wrapper for the UIKit view, enabling us to integrate UIKit components into our SwiftUI views. In this tutorial, we’ll use it to create a SwiftUI TextField alternative with the flexibility of the UITextField. We will do it in several steps.

Basics

First, create a struct CustomTextField that conforms to the UIViewRepresentable protocol. This struct has a Binding variable of the type String representing the entered text. 

struct CustomTextField: UIViewRepresentable {
    
   @Binding private var text: String
    
   init(text: Binding<String>) {
     	 self._text = text
   }
    
   func makeUIView(context: Context) -> UITextField {
       .init()
   }
    
   func updateUIView(_ uiView: UITextField, context: Context) {
        
   }
            
}

Then, we need to create our UITextField inside the makeUIView function.

func makeUIView(context: Context) -> UITextField {
     let textField = UITextField()
     textField.delegate = context.coordinator
     
     return textField
}

We need to update our text field with the help of the updateUIView function.

func updateUIView(_ uiView: UITextField, context: Context) {
     configure(uiView)
}

private func configure(_ textField: UITextField) {
     textField.text = text
}

So, who will be the delegate of our text field? This honor will go to the coordinator object. Let’s create it.

final class Coordinator: NSObject, UITextFieldDelegate {
    
    @Binding private var text: String
        
    init(text: Binding<String>) {
        self._text = text
    }
        
    func textField(
        _ textField: UITextField,
        shouldChangeCharactersIn range: NSRange,
        replacementString string: String
    ) -> Bool {
            
       true
    }
        
}

And that’s it! Our CustomTextField View is ready, and we can use it on another SwiftUI View. 

struct ContentView: View {

    @State private var text = ""
    
    var body: some View {
        CustomTextField(text: $text)
    }
    
}

UITextField events

As we know, UITextField can notify us about events inside it, such as the start of editing or its completion. SwiftUI provides tools for implementing these actions, but as we use UITextField in this tutorial, we can utilize the UIKit framework and create a convenient interface for its configuration.

However, let’s take a quick look at how this can be done within the SwiftUI.

struct ContentView: View {

    @State private var text = ""    
    @FocusState private var isFocused: Bool
    
    var body: some View {
        
        TextField("Search", text: $text)
            .focused($isFocused)
            .onChange(of: isFocused) { _, isFocused in
                if isFocused {
                    // begin editing
                } else {
                    // end editing
                }
            }
    }
    
}

Now, let’s focus on our text field. I propose a flexible solution: creating several closures. You can adjust their range to align with your specific needs perfectly. Also let’s add a new isFirstResponder property.

struct CustomTextField: UIViewRepresentable {
    
    var didBeginEditing: () -> Void = {}
    var didChange: () -> Void = {}
    var didEndEditing: () -> Void = {}
    var shouldReturn: () -> Void = {}
    
    @Binding private var isFirstResponder: Bool
    
...

These closures will be passed to the coordinator, and their values will be provided through the initializer. We must implement the appropriate text field delegate methods inside the coordinator and add the isFirstResponder property.

isFirstResponder

Let’s have a closer look at the isFirstResponder property. We should handle it both in the makeUIView function and the updateUIView function.

init(
        text: Binding<String>,
        isFirstResponder: Binding<Bool> = Binding<Bool>(get: { false }, set: { _ in })
    ) {
        
        self._text = text
        self._isFirstResponder = isFirstResponder
    }
func updateUIView(_ uiView: UITextField, context: Context) {
      configure(uiView)
        
      if isFirstResponder {
          uiView.becomeFirstResponder()
      } else {
          uiView.resignFirstResponder()
      }
}
func makeUIView(context: Context) -> UITextField {
      let textField = UITextField()
      textField.delegate = context.coordinator
        
      if isFirstResponder {
          textField.becomeFirstResponder()
      }
        
      return textField
}

Coordinator

Now, let’s focus on our coordinator. Its delegate methods will work in conjunction with isFirstResponder property. To handle the editingChanged event, we should add a target to our text field in the makeUIView function and mark the appropriate coordinator function with the @objc attribute.

func makeUIView(context: Context) -> UITextField {
    let textField = UITextField()
    textField.delegate = context.coordinator
    
    if isFirstResponder {
        textField.becomeFirstResponder()
    }
    
    textField.addTarget(
        context.coordinator,
        action: #selector(Coordinator.textFieldDidChange),
        for: .editingChanged
    )
    return textField
}
final class Coordinator: NSObject, UITextFieldDelegate {
    
    @Binding var text: String
    var inputAccessoryViewFactory: InputAccessoryViewFactoryProtocol?
    
    @Binding private var isFirstResponder: Bool
    
    private var didBeginEditing: () -> Void
    private var didChange: () -> Void
    private var didEndEditing: () -> Void
    private var shouldReturn: () -> Void
    
    init(
        text: Binding<String>,
        isFirstResponder: Binding<Bool>,
        inputAccessoryViewFactory: InputAccessoryViewFactoryProtocol?,
        didBeginEditing: @escaping () -> Void,
        didChange: @escaping () -> Void,
        didEndEditing: @escaping () -> Void,
        shouldReturn: @escaping () -> Void
    ) {
        
        self._text = text
        self._isFirstResponder = isFirstResponder
        self.inputAccessoryViewFactory = inputAccessoryViewFactory
        self.didBeginEditing = didBeginEditing
        self.didChange = didChange
        self.didEndEditing = didEndEditing
        self.shouldReturn = shouldReturn
    }
    
    func textField(
        _ textField: UITextField,
        shouldChangeCharactersIn range: NSRange,
        replacementString string: String
    ) -> Bool {
        
        true
    }
    
    func textFieldDidBeginEditing(_ textField: UITextField) {
        DispatchQueue.main.async { [self] in
            text = textField.text ?? ""
            
            if !isFirstResponder {
                isFirstResponder = true
            }
            if textField.clearsOnBeginEditing {
                text = ""
            }
            didBeginEditing()
        }
    }
    
    @objc func textFieldDidChange(_ textField: UITextField) {
        text = textField.text ?? ""
        didChange()
    }
    
    func textFieldDidEndEditing(
        _ textField: UITextField,
        reason: UITextField.DidEndEditingReason
    ) {
        
        DispatchQueue.main.async { [self] in
            if isFirstResponder {
                isFirstResponder = false
            }
            
            didEndEditing()
        }
    }
    
    func textFieldShouldReturn(_ textField: UITextField) -> Bool {
        isFirstResponder = false
        shouldReturn()
        return false
    }
    
}

Event handling

We can create an extension to define actions for these closures to handle these events outside. Something like this:

extension CustomTextField {
    
    func onEdit(_ action: (() -> Void)? = nil) -> CustomTextField {
        var view = self
        guard let action else {
            return view
        }
        view.didChange = action
        
        return view
    }
    
}

struct ContentView: View {
    
    @State private var text = ""
    
    var body: some View {
        CustomTextField(text: $text)
            .onEdit {
                print(text2)
            }
    }
    
}

Conclusion

Everything we have done today is the basis for future improvements, allowing us to create the text field of our dreams. We will continue in the next part of this article. See you there!

By the way, the code is available in this repository.