TextField e UIViewRepresentable

Costruiamo un componente custom per la gestione dell'input utente

Uno dei dubbi più grandi nella prima mezz’ora di uso di SwiftUI dopo la presentazione alla WWDC, sono state sicuramente le TextField. Non esisteva un modo per dismettere la tastiera, ne un modo per spostare il focus da una TextField all’altra.


probabilmente, alcuni dettagli non erano ancora pronti durante la presentazione iniziale.


La beta2 di iOS13, insieme a tante altre migliorie, sembra risolvere almeno in parte i problemi legati alle TextField: alla pressione del tasto invio la tastiera viene dismessa correttamente. Non c’è nessuna traccia, però, di un meccanismo per cambiare il fuoco tra più TextFieldPurtroppo, bisogna anche segnalare un importante bug nella beta2 che affligge il meccanismo di chiamata della closure onCommitHandler alla chiusura della tastiera; quest’ultima non viene mai chiamata.

In quest’articolo proveremo ad aggirare tutti questi problemi creando una TextField personalizzata prendendo tutto il buono che c’è attualmente in SwiftUI ed in UIKit. I lettori più attenti avranno già capito dove voglio arrivare: 


Costruiremo un componente personalizzato in UIKit che utilizza la vecchia e perfettamente funzionante UITextField e lo esporremo in SwiftUI attraverso il protocollo UIViewRepresentable.


Mettiamoci al lavoro!

UITextField e delegati

UIKit mette a disposizione un oggetto denominato UITextField che si avvale dell'uso della tecnica dei delegati per poter offrire un'esperienza di interazione con la tastiera fluida ed efficace per le proprie applicazioni. 

Quello che faremo sarà creare una classe che eredita da UITextField e, contemporaneamente, adotta il protocollo UITextFieldDelegate. In questo modo avremo un unico oggetto con cui dialogare per l'integrazione con SwiftUI.


La prima cosa da fare è dichiarare la nostra classe 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) {
	...
    }
}

Nel riquadro sopra è rappresentato lo scheletro dei metodi delegati che implementeremo per completare le funzionalità della nostra classe.

Come anticipato, la nostra classe estende UITextField, adottandone il protocollo delegato UITextFieldDelegate. Abbiamo aggiunto, inoltre, due variabili locali che contengono due closure opzionali. Rispettivamente:

- La closure textFieldChangedHandler, verrà invocata ogni qualvolta ci sarà un cambio della UITextField.

- La closure onCommitHandler, verrà invocata al termine dell'editing con la pressione del tasto Invio.


 Vediamo adesso, metodo per metodo le singole implementazioni da aggiungere alla nostra classe.

textField:shouldChangeCharactersInRange:replacementString:

Il metodo delegato textField:shouldChangeCharactersInRange:replacementString: viene invocato automaticamente dal sistema ogni qualvolta digitiamo qualcosa sul tastierino. Esso ci permette di effettuare controlli sui contenuti immessi e restituire un valore booleano true/false a seconda se il contenuto inserito è permesso o meno dalle nostre politiche 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, ci basterà comporre la nuova stringa risultante dall'inserimento dell'utente e quella attualmente presente a schermo. Per semplicità, convertiamo la stringa di tipo String in una tradizionale NSString che mette a disposizione tutti gli strumenti per dialogare in maniera diretta con il tipo NSRange fornito come parametro in input dal metodo. 

Una volta convertita la stringa, applichiamo su di essa la trasformazione proposta attraverso il metodo:

replacingCharacters(in: range, with: string)

Passando come parametri i due valori forniti in input dal metodo. Rispettivamente: range e la stringa inserita dall’utente. Il risultato sarà la composizione tra l’input fornito dall’utente e l’attuale contenuto della UITextField.

Questo valore sarà quello che forniremo in input alla closure definita precedentemente:

textFieldChangedHandler?(proposedValue as String)

textFieldDidEndEditing:

L'implementazione del metodo delegato invocato al completamento dell'inserimento è molto semplice: dovrà soltanto invocare la closure definita in precedenza. In questo modo potremo eseguire una porzione di codice arbitrario ogni volta che l’editing verrà concluso.


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

textFieldShouldReturn:

L’ultimo metodo che ci resta da implementare è textFieldShouldReturn. La sua implementazione ci permetterà di costruire un meccanismo primordiale per spostare il focus della nostra TextField su altre classi analoghe contenute all’interno di una stessa view contenitore. Vediamo come funziona.

La prima cosa che facciamo è chiedere all'attuale elemento textField di referenziare la propria superview?.superview? seguendo uno schema simile al seguente:

scaffoldingWrapperView

Fig.1 - Schema che semplifica lo scaffolding all'interno del contenitore VStack.

Se viene restituito un oggetto valido, invocheremo su di esso il metodo viewWithTag passandogli l'attuale tag incrementato di una unità. In questo modo abbiamo effettuato uno scaffolding molto elementare che ci permette di risalire sino al contenitore e chiedere ad esso altre view con uno specifico 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
}

Se il valore restituito esiste ed è di tipo UITextField, allora abbiamo trovato un'altra UITextField e possiamo passarle il controllo. In caso contrario, dismettiamo semplicemente il controllo della tastiera.

La nostra classe Wrappable è pronta. Non ci resta che innestarla all'interno di SwiftUI.

UIViewControllerRepresentable

SwiftUI mette a disposizione un utilissimo protocollo denominato UIViewControllerRepresentable con cui è possibile esporre tutti quegli oggetti di UIKit che, ad oggi, non sono stati integrati nativamente in SwiftUI.

Adottando questo protocollo è possibile costruire un'oggetto che espone il contenuto di una UIView nativamente in SwiftUI. Ci basterà implementare due metodi:

struct SATextField: UIViewRepresentable {

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

Il primo metodo è responsabile della creazione della nostra UIView ed il suo passaggio a SwiftUI in un formato compatibile con quello delle View che abbiamo visto nei tutorial precedenti.

Il secondo metodo verrà invocato ogni qualvolta la nostra UIView dovrà essere aggiornata per riflettere un cambiamento nell'interfaccia utente (un pò come avviene per la proprietà body che abbiamo visto negli oggetti nativi SwiftUI).


Aggiungiamo, infine, alcune variabili locali al nostro oggetto che useremo per pilotare il comportamento della classe 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 alla creazione dell'oggetto SwiftUI. Essi verranno passati alla classe Wrapper istanziata ed esprimono rispettivamente: il valore del tag da associare alla TextField, le closure necessarie per gestire la variazione dell'input dell'utente e la fine delle modifiche al campo ed il valore da associare al placeholder della nostra UITextField.

Vediamo, a questo punto, l'implementazione dei due metodi richiesti dal protocollo.

Il contenuto del metodo makeUIView associa i valori inseriti dall'utente ai relativi parametri esposti dalla classe 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, imposterà le priorità per la resistenza al ridimensionamento della nostra UITextField, così da adattarsi a tutto lo spazio a disposizione della View contenitore in maniera coerente con gli altri oggetti:

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

Un esempio pratico

Proviamo adesso ad usare la nostra nuova 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))
        }
    }
}

Ecco fatto. In poche righe di codice abbiamo creato una TextField responsiva al cambio di focus ed al completamento dell'editing, nell'attesa che la prossima beta di iOS13 risolva gli attuali problemi dell'oggetto TextField nativo.

Come al solito, potete scaricare il codice sorgente dal link sotto. 

--A presto!


Scarica il codice sorgente

© Copyright 2019 Sofapps - All Rights Reserved - VAT 05197020877