TextField and UIViewRepresentable

Jun 28, 2019 · 10 mins read · Costantino Pistagna · @valv0

TextField and UIViewRepresentable

At a glance, when I first tried SwiftUI after the WWDC19 introduction, one of the biggest concern was certainly related to the TextField object: there was no way to dispose the keyboard, nor a way to move the focus from one TextField to another.

probably, some details were not yet ready during the initial presentation.

iOS13 beta2, along with many other improvements, seems to solve at least in part the problems related to the TextField: when the enter key is pressed, the keyboard is now dismissed correctly. There are no clues, however, about a mechanism to change the focus between multiple TextField(s) within the same View container. Unfortunately, we must also report an important bug in beta2 that affects the call mechanism for the onCommitHandler closure when the keyboard is dismissed: the latter is never called at all.

In this article we will try to work around all these problems by creating a custom TextField taking all the good that is currently in SwiftUI and UIKit. The smartest readers, probably, will already figured out where I want to go:

We will build a custom component in UIKit that uses the old fashioned and perfectly functioning UITextField and we will expose it in SwiftUI through the UIViewRepresentable protocol.


UITextField and delegates

UIKit provides an object called UITextField which take advantage of the delegate technique in order to offer a fluid and effective keyboard experience.

What we will do is creating a class that inherits from UITextField and, simultaneously, adopts the UITextFieldDelegate protocol. This way we will have a single object to expose for integration with SwiftUI.

The first thing to do is to declare our class, which we will call WrappableTextField:

class WrappableTextField: UITextField, UITextFieldDelegate {
    var textFieldChangedHandler: ((String)->Void)?
    var onCommitHandler: (()->Void)?
    
    func textFieldShouldReturn(_ textField: UITextField) -> Bool {
	...
    }
    
    func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
	...
    }
    
    func textFieldDidEndEditing(_ textField: UITextField) {
	...
    }
}

The code above shows the skeleton of delegate methods we will implement to complete the Wrapper class.

As anticipated, our class extends UITextField, adopting the delegated protocol UITextFieldDelegate. We also added two local variables that contain two custom optional closures. Respectively:

  • The closure textFieldChangedHandler, will be invoked whenever there is a change of the UITextField.

  • The closure onCommitHandler, will be invoked at the end of editing by pressing the Enter key.

Let’s see now, step by step the individual method implementations that will be added to our class.

textField:shouldChangeCharactersInRange:replacementString:

The delegate method shouldChangeCharactersInRange:replacementString: is automatically invoked by the system whenever we type something on the keyboard. It allows us to perform checks on the content, returning a boolean value depending on whether the content entered is allowed or not by our validation policies.

func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
    if let currentValue = textField.text as NSString? {
        let proposedValue = currentValue.replacingCharacters(in: range, with: string)
        textFieldChangedHandler?(proposedValue as String)
    }
    return true
}

In our case, it will be suffice to compose the new string resulting from the insertion of the user input and the one currently present on the UITextField. For simplicity, we convert the String type into a foundation NSString, which provides all the tools to communicate directly with the NSRange object supplied as an input parameter by the method.

Once the string has been converted, we apply on it the transformation proposed through the method:

replacingCharacters(in: range, with: string)

Passing the two values supplied as input by the method as parameters. Respectively, range and the string entered by the user. The result will be the composition between the input provided by the user and the current content of the UITextField.

This value will be the one we will provide in input to the closure defined previously:

textFieldChangedHandler?(proposedValue as String)

textFieldDidEndEditing:

The implementation of this method, triggered by the system to complete the editing phase, is very simple: it will only have to invoke our custom closure defined previously. This way we will be able to execute an arbitrary chunk of code every time the editing is completed.

func textFieldDidEndEditing(_ textField: UITextField) {
    onCommitHandler?()
}
textFieldShouldReturn:

The last method we have to implement is textFieldShouldReturn. Its implementation will allow us to build a very simple mechanism to move focus from our TextField into other similar classes contained within the same container view. Let’s see how it works.

The first thing we do is to ask the current textField element a reference to its own superview?.superview? following a pattern similar to the one depicted in the following schema:

Fig.1

If a valid object is returned, we will invoke its viewWithTag method, passing the current tag incremented by one. This way we have managed to create a very elementary scaffolding workflow, that allows us to go back up to the container and ask it for other views with a specific tag.

func textFieldShouldReturn(_ textField: UITextField) -> Bool {
    if let nextField = textField.superview?.superview?.viewWithTag(textField.tag + 1) as? UITextField {
        nextField.becomeFirstResponder()
    } else {
        textField.resignFirstResponder()
    }
    return false
}

If the returned value exists and is of type UITextField, then we have found another UITextField and we can pass the control to it. Otherwise, we will simply dismiss the keyboard.

Our Wrappable class is ready. Now, we just have to plug it into SwiftUI.

UIViewControllerRepresentable

SwiftUI provides a very useful protocol called UIViewControllerRepresentable allowing us to expose all those UIKit objects that, to date, have not been natively integrated into SwiftUI.

By adopting this protocol it is possible to construct an object that exposes the content of an UIView natively to SwiftUI. It require to implement just two methods:

struct SATextField: UIViewRepresentable {

    func makeUIView(context: UIViewRepresentableContext<SATextField>) -> WrappableTextField {
	...
    }
    
    func updateUIView(_ uiView: WrappableTextField, context: UIViewRepresentableContext<SATextField>) {
	...
    }
}

The first method is responsible for the creation of our UIView as well as providing it to SwiftUI in a compatible some View format allowed by the SwiftUI protocol as we saw in the previous tutorials.

The second method will be invoked whenever our UIView needs to be updated to reflect a change in the user interface (a bit like the body property we saw in SwiftUI native objects).

Finally, let’s add some local variables to our object that we will use to drive the behavior of the WrappableTextField class previously declared:

struct SATextField: UIViewRepresentable {
    private let tmpView = WrappableTextField()
    var tag:Int = 0
    var placeholder:String?
    var changeHandler:((String)->Void)?
    var onCommitHandler:(()->Void)?

    ...
}

These variables will be the required parameters to create the SwiftUI object. They will be passed to the previously instantiated Wrapper class and express respectively: the value of the tag to be associated to the TextField, the closures necessary to manage the user input changes, the end editing and the value to associate to the placeholder of our UITextField.

The content of the makeUIView method, just associates the parameters entered by the user during the SwiftUI object creation with the corresponding parameters exposed by the WrappableTextField class.

func makeUIView(context: UIViewRepresentableContext<SATextField>) -> WrappableTextField {
    tmpView.tag = tag
    tmpView.delegate = tmpView
    tmpView.placeholder = placeholder
    tmpView.onCommitHandler = onCommitHandler
    tmpView.textFieldChangedHandler = changeHandler
    return tmpView
}

The updateUIView method will set the hugging priority of our UITextField to adapt to all available space within the Container View, consistently with all the other objects:

func updateUIView(_ uiView: WrappableTextField, context: UIViewRepresentableContext<SATextField>) {
    uiView.setContentHuggingPriority(.defaultHigh, for: .vertical)
    uiView.setContentHuggingPriority(.defaultLow, for: .horizontal)
}

Wrap it up: a real world example

Let’s try using our brand new SATextField in a practical example:

struct ContentView : View {
    @State var username:String = ""
    @State var email:String = ""
    
    var body: some View {
        VStack {
            HStack {
                Text(username)
                Spacer()
                Text(email)
            }
            SATextField(tag: 0, placeholder: "@username", changeHandler: { (newString) in
                self.username = newString
            }, onCommitHandler: {
                print("commitHandler")
            }).padding(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8))
            SATextField(tag: 1, placeholder: "@email", changeHandler: { (newString) in
                self.email = newString
            }, onCommitHandler: {
                print("commitHandler")
            }).padding(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8))
        }
    }
}

All done folks! In a few lines of code we have created a TextField object responsive to the focus changes and the end editing completion, waiting for the next beta of iOS13 to solve the current problems of the native TextField object.

Fig.1

Costantino Pistagna
Costantino Pistagna · @valv0 Costantino is a software architect, project manager and consultant with more than ten years of experience in the software industry. He developed and managed projects for universities, medium-sized companies, multi-national corporations, and startups. He is among the first teachers for the Apple's iOS Developer Academy, based in Europe. Regularly, he lectures iOS development around the world, giving students the skills to develop their own high quality apps. While not writing apps, Costantino improves his chefs skills, travelling the world with his beautiful family.