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 solution: creating several closures. You can adjust their range to align with your specific needs perfectly. Also, let’s add a new focusedField and equalField properties.
struct CustomTextField<Field: Hashable>: UIViewRepresentable {
var didBeginEditing: () -> Void = {}
var didChange: () -> Void = {}
var didEndEditing: () -> Void = {}
var shouldReturn: () -> Void = {}
@Binding private var text: String
@Binding private var focusedField: Field?
private let equalField: Field
private var isFirstResponder: Bool {
focusedField == equalField
}
...
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 properties to handle UITextField’s isFirstResponder state.
isFirstResponder
Let’s have a closer look at the focusedField property. We should handle it both in the makeUIView function and the updateUIView function.
@Binding private var focusedField: Field?
private let equalField: Field
private var isFirstResponder: Bool {
focusedField == equalField
}
init(
_ placeholder: String,
text: Binding<String>,
focusedField: Binding<Field?>,
equals: Field
) {
self.placeholder = placeholder
equalField = equals
_text = text
_focusedField = focusedField
}
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 focusedField and equalField properties. 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
}
public final class Coordinator: NSObject, UITextFieldDelegate {
...
@Binding private var text: String
@Binding private var focusedField: Field?
private let equalField: Field
private var didBeginEditing: () -> Void
private var didChange: () -> Void
private var didEndEditing: () -> Void
private var shouldReturn: () -> Void
private var shouldClear: () -> Void
...
private var isFirstResponder: Bool {
focusedField == equalField
}
init(
text: Binding<String>,
focusedField: Binding<Field?>,
equalField: Field,
...
) {
...
}
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 {
focusedField = equalField
}
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 {
focusedField = nil
}
didEndEditing()
}
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
focusedField = nil
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)?) -> CustomTextField {
var view = self
guard let action else {
return view
}
view.didChange = action
return view
}
}
struct ContentView: View {
enum FieldKind {
case username, password
}
@State var focusedField: FieldKind?
@State var username = ""
@State var password = ""
var body: some View {
ScrollView {
VStack {
CustomTextField(
"Password",
text: $password,
focusedField: $focusedField,
equals: .password
)
.onEdit {
...
}
}
.padding()
}
}
}
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.