TextField e UIViewRepresentable
Come aggirare i limiti del TextField di iOS 13 beta 2 incapsulando UITextField e il suo delegate in una UIViewRepresentable, con il focus tra i campi.
Tradotto dall’inglese con l’aiuto dell’IA e rivisto dall’autore.Leggi l’originale

A prima vista, quando ho provato SwiftUI per la prima volta dopo la presentazione del WWDC19, una delle preoccupazioni più grandi riguardava sicuramente l'oggetto TextField: non c'era modo di chiudere la tastiera, né di spostare il focus da un TextField all'altro.
probabilmente alcuni dettagli non erano ancora pronti durante la presentazione iniziale.
iOS13 beta2, insieme a molti altri miglioramenti, sembra risolvere almeno in parte i problemi legati al TextField: quando si preme il tasto Invio, la tastiera ora viene chiusa correttamente. Non ci sono indizi, però, su un meccanismo per cambiare il focus tra più TextField all'interno dello stesso container View. Purtroppo dobbiamo anche segnalare un bug importante della beta2 che riguarda il meccanismo di chiamata della closure onCommitHandler quando la tastiera viene chiusa: quest'ultima non viene mai chiamata.
In questo articolo proveremo ad aggirare tutti questi problemi creando un TextField personalizzato che prende il meglio di quello che c'è oggi in SwiftUI e in UIKit. I lettori più attenti avranno probabilmente già capito dove voglio arrivare:
Costruiremo un componente personalizzato in UIKit che usa la vecchia e perfettamente funzionante UITextField e lo esporremo in SwiftUI attraverso il protocol UIViewRepresentable.
UITextField e i delegate
UIKit mette a disposizione un oggetto chiamato UITextField che sfrutta la tecnica del delegate per offrire un'esperienza di tastiera fluida ed efficace.
Quello che faremo è creare una class che eredita da UITextField e, allo stesso tempo, adotta il protocol UITextFieldDelegate. In questo modo avremo un unico oggetto da esporre per l'integrazione con SwiftUI.
La prima cosa da fare è dichiarare la nostra class, che chiameremo 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) {
...
}
}Il codice qui sopra mostra lo scheletro dei metodi delegate che implementeremo per completare la class Wrapper.
Come anticipato, la nostra class estende UITextField e adotta il protocol delegate UITextFieldDelegate. Abbiamo anche aggiunto due variabili locali che contengono due closure opzionali personalizzate. Rispettivamente:
-
La closure textFieldChangedHandler verrà invocata ogni volta che la UITextField cambia.
-
La closure onCommitHandler verrà invocata al termine dell'editing, alla pressione del tasto Invio.
Vediamo ora, passo per passo, le singole implementazioni dei metodi che aggiungeremo alla nostra class.
textField:shouldChangeCharactersInRange:replacementString:
Il metodo delegate shouldChangeCharactersInRange:replacementString: viene invocato automaticamente dal sistema ogni volta che scriviamo qualcosa con la tastiera. Ci permette di eseguire controlli sul contenuto, restituendo un valore booleano a seconda che il contenuto inserito sia permesso o meno dalle nostre regole di validazione.
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
}Nel nostro caso basterà comporre la nuova stringa risultante dall'inserimento dell'input dell'utente e di quella attualmente presente nella UITextField. Per semplicità convertiamo il tipo String in un NSString di Foundation, che offre tutti gli strumenti per comunicare direttamente con l'oggetto NSRange fornito come parametro di input dal metodo.
Una volta convertita la stringa, applichiamo la trasformazione proposta dal metodo:
replacingCharacters(in: range, with: string)
passando come parametri i due valori forniti in input dal metodo: range e la stringa inserita dall'utente. Il risultato sarà la composizione tra l'input dell'utente e il contenuto attuale della UITextField.
Questo valore sarà quello che passeremo in input alla closure definita in precedenza:
textFieldChangedHandler?(proposedValue as String)
textFieldDidEndEditing:
L'implementazione di questo metodo, richiamato dal sistema al termine della fase di editing, è molto semplice: dovrà solo invocare la closure personalizzata definita prima. In questo modo potremo eseguire un blocco di codice arbitrario ogni volta che l'editing termina.
func textFieldDidEndEditing(_ textField: UITextField) {
onCommitHandler?()
}textFieldShouldReturn:
L'ultimo metodo da implementare è textFieldShouldReturn. La sua implementazione ci permetterà di costruire un meccanismo molto semplice per spostare il focus dal nostro TextField ad altre class simili contenute nella stessa container view. Vediamo come funziona.
La prima cosa che facciamo è chiedere all'elemento textField corrente un riferimento alla propria superview?.superview?, seguendo uno schema simile a quello rappresentato nel diagramma seguente:

Se viene restituito un oggetto valido, invochiamo il suo metodo viewWithTag, passando il tag corrente incrementato di uno. In questo modo siamo riusciti a creare un meccanismo di navigazione molto elementare, che permette di risalire fino al container e chiedergli altre view con un tag specifico.
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
if let nextField = textField.superview?.superview?.viewWithTag(textField.tag + 1) as? UITextField {
nextField.becomeFirstResponder()
} else {
textField.resignFirstResponder()
}
return false
}Se il valore restituito esiste ed è di tipo UITextField, abbiamo trovato un'altra UITextField e possiamo passarle il controllo. Altrimenti chiudiamo semplicemente la tastiera.
La nostra class Wrappable è pronta. Ora non resta che collegarla a SwiftUI.
UIViewRepresentable
SwiftUI mette a disposizione un protocol molto utile, chiamato UIViewRepresentable, che permette di esporre tutti quegli oggetti UIKit che finora non sono stati integrati nativamente in SwiftUI.
Adottando questo protocol è possibile costruire un oggetto che espone nativamente a SwiftUI il contenuto di una UIView. Richiede di implementare solo due metodi:
struct SATextField: UIViewRepresentable {
func makeUIView(context: UIViewRepresentableContext<SATextField>) -> WrappableTextField {
...
}
func updateUIView(_ uiView: WrappableTextField, context: UIViewRepresentableContext<SATextField>) {
...
}
}Il primo metodo si occupa di creare la nostra UIView e di fornirla a SwiftUI in un formato some View compatibile, ammesso dal protocol di SwiftUI come abbiamo visto nei tutorial precedenti.
Il secondo metodo verrà invocato ogni volta che la nostra UIView deve essere aggiornata per riflettere un cambiamento nell'interfaccia utente (un po' come la property body che abbiamo visto negli oggetti nativi di SwiftUI).
Infine aggiungiamo al nostro oggetto alcune variabili locali che useremo per guidare il comportamento della class WrappableTextField dichiarata in precedenza:
struct SATextField: UIViewRepresentable {
private let tmpView = WrappableTextField()
var tag:Int = 0
var placeholder:String?
var changeHandler:((String)->Void)?
var onCommitHandler:(()->Void)?
...
}Queste variabili saranno i parametri richiesti per creare l'oggetto SwiftUI. Verranno passate alla class Wrapper istanziata prima ed esprimono, rispettivamente: il valore del tag da associare al TextField, le closure necessarie per gestire i cambiamenti dell'input dell'utente e la fine dell'editing, e il valore da associare al placeholder della nostra UITextField.
Il contenuto del metodo makeUIView associa semplicemente i parametri inseriti dall'utente durante la creazione dell'oggetto SwiftUI ai parametri corrispondenti esposti dalla class WrappableTextField.
func makeUIView(context: UIViewRepresentableContext<SATextField>) -> WrappableTextField {
tmpView.tag = tag
tmpView.delegate = tmpView
tmpView.placeholder = placeholder
tmpView.onCommitHandler = onCommitHandler
tmpView.textFieldChangedHandler = changeHandler
return tmpView
}Il metodo updateUIView imposta la hugging priority della nostra UITextField in modo che si adatti a tutto lo spazio disponibile nel Container View, in modo coerente con tutti gli altri oggetti:
func updateUIView(_ uiView: WrappableTextField, context: UIViewRepresentableContext<SATextField>) {
uiView.setContentHuggingPriority(.defaultHigh, for: .vertical)
uiView.setContentHuggingPriority(.defaultLow, for: .horizontal)
}Mettiamo tutto insieme: un esempio reale
Proviamo a usare il nostro nuovissimo SATextField in un esempio pratico:
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))
}
}
}Fatto, ragazzi! Con poche righe di codice abbiamo creato un oggetto TextField che risponde ai cambi di focus e al completamento dell'editing, in attesa che la prossima beta di iOS13 risolva i problemi attuali dell'oggetto TextField nativo.

