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.