AndroidKotlin

Kotlin – Formulaire – partie 1

By 15 février 2019 No Comments

Dans cette article, nous allons voir comment valider ou invalider automatiquement chaque champs (EditText) d’un formulaire.

La validation du formulaire en lui même, vérifiant que tous les champs de celui-ci sont valide sera vu dans un second article disponible sur ce lien.

Le projet de cette article est disponible ici.

Il a volontairement été « surchargé » par des commentaires et des logs, pour mieux comprendre le mécanisme de la validation.

Le principe est de surcharger le TextInputLayout pour pouvoir le personnaliser, et inclure automatiquement le TextInputEditText, personnalisé en fonction du type de champ que l’on souhaitera mettre en place (mot de passe, email,…)

1 – Petit rappel sur le TextInputLayout/TextInput EditText

Si vous utilisez le TextInputLayout/TextInputEditText, vous le mettrez en place de la façon suivante :

<android.support.design.widget.TextInputLayout
    android:id="@+id/text_input_email"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:errorEnabled="true">

    <android.support.design.widget.TextInputEditText
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="Votre E-mail"
        android:inputType="textEmailAddress"
    />

</android.support.design.widget.TextInputLayout>

2 – But de cet article

Nous allons remplacer le textInputLayout/TextInputEditText ci-dessus par le code suivant :

<xxx.xxxxxx.PersoTextInputLayout
    android:id="@+id/text_input_email"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"

    app:input_type="email"
    app:hide_valid_indicator="false"
    app:color_style="@style/PersoEditTextBlue.EditText"
    app:edit_text_hint="Hint personnalisé"
/>

Vous pouvez voir 4 propriétés applicatives :

  • app:input_type
  • app:hide_valid_indicator
  • app:color_style
  • app:edit_text_hint

app:input_type : correspond au type de l’editText que vous souhaitez. Le projet contient les types suivants, cependant vous pouvez en ajouter comme bon vous semble :

  • first_name
  • last_name
  • email
  • email_confirmation
  • address
  • postal_code
  • city
  • phone
  • mobile_phone
  • landline_phone
  • birthday
  • password
  • new_password
  • old_password
  • confirm_password
  • birthday_older_than_18

app:hide_valid_indicator :  Vous permet de cacher l’icone de validation lorsque le champ est valide. Facultatif. Par défaut, l’icone est affiché

app:color_style : Vous permet de styliser le champ. Facultatif. Par défaut, il prendra le style « PersoTextInputLayout.EditText »

app:edit_text_hint : Vous permet de personnaliser le hint du champ. Facultatif. Par défaut, il prend une valeur différente en fonction du type défini. Ces valeurs sont définies dans le fichier strings.xml, et commencent par « common_field… »

Vous retrouverez aussi les messages d’erreurs dans ce même fichier, commençant par « form_error_… »

3 – Comportement au niveau de l’activité

Au niveau de l’activité, vous pouvez influencer le comportement de la validation de chaque champ de la façon suivante :

Si on reprend le champ eMail ci-dessus (@+id/text_input_email) :

3.1 – Désactivation de la validation du champ
  • text_input_email.disablePatternValidation()
text_input_email.disablePatternValidation()

Cela va permettre d’ignorer la vérification du champ pour savoir si celui-ci est valide ou non.

Dans ce cas, et après un appel à la fonction text_input_email.validate(), ce champ sera alors valide.

Vous pouvez le vérifier par text_input_email.isValid().

 

3.2 – Vérification de deux champs identiques

Si nous avons un 2eme champ, ayant l’id text_input_email_confirmation, de type email_comfirmation, vous pouvez lier les 2 champs de la façon suivante :

  • text_input_email_confirmation.isIdenticalTo(text_input_email)
text_input_email_confirmation.isIdenticalTo(text_input_email)

Dans ce cas, lorsque l’utilisateur renseignera ce champ, une action supplémentaire, permettant de vérifier si les 2 valeurs de ces deux champs sont identiques, sera effectuée.

 

3.3 – Validation manuelle d’un champ

Si vous souhaitez effectuer la vérification manuellement, vous pouvez le faire de la façon suivante :

  • text_input_email.withExternalValidator(…)
etEmail.withExternalValidator(object : PersoTextInputLayout.Validator {
            override fun isValid(): Boolean {
                // Faire la vérification
                return true // or False !
            }

 

Vous retrouverez dans l’activité FormActivity du projet les exemples ci-dessus.

4 – Exemple d’un cas de figure

Utilisation du type : landline_phone

 

  • Avec un mauvais numéro de téléphone

  • Avec un numéro de téléphone fixe correct

  • Avec un téléphone fixe incorrect et :
    landLinePhone.disablePatternValidation()  – Au niveau de l’activité

Dans ce cas, la validation n’étant plus active, le champ est alors toujours valide :

landLinePhone.isValid()  // = true
  • Avec un téléphone fixe incorrect et
    • landLinePhone.disablePatternValidation()  – Au niveau de l’activité
    • app:hide_valid_indicator= « true » – Au niveau du layout

Dans ce cas, la validation n’étant plus active, le champ est alors toujours valide :

landLinePhone.isValid() // = true
  • Avec un numéro de téléphone correct et
    • app:hide_valid_indicator= « true » – Au niveau du layout

Dans ce cas, la validation étant active, et le champ étant valide:

landLinePhone.isValid() // = true

5 – Exemple de visualisation du projet

 

6 – Les principaux fichiers du projet

6.1 – L’activité
class FormActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_form)

        /********************************************************************/
        /* TEST du comportement de disablePatternValidation                 */
        /********************************************************************/
        // On met une valeur incorrecte dans le champ du téléphone
        phone.value="44"   // A ce stade, le message d'erreur ainsi que la croix est affichée dans la view

        Log.d("LOGFORM","Validation du phone pattern = ${phone.isValid()}") // La valeur est "false"

        // On demande de ne plus faire de vérification du champ
        phone.disablePatternValidation()
        // On refait une demande de validation
        phone.validate()

        // La valeur de phone.isValid() est maintenant à true.
        Log.d("LOGFORM","Validation du phone pattern = ${phone.isValid()}")

        /********************************/
        /* FIN DU TEST                  */
        /********************************/

        /**********************************************************************************************************/
        /*Permet de faire la vérification/validation en externe au lieu de le faire dans PersoTextInputLayout     */
        /**********************************************************************************************************/
        /*
         etEmail.withExternalValidator(object : PersoTextInputLayout.Validator {
            override fun isValid(): Boolean {
                // Faire la vérification
                return true // or False !
            }
        })
        */

        /********************************/
        /* FIN DU TEST                  */
        /********************************/

        /*****************************************************************************************/
        /* Permet de vérifer que le mot de passe de confirmation est identique au mot de passe   */
        /* La vérification se fera lors de la perte du focus sur le mot de passe de confirmation */
        /*****************************************************************************************/
        passwordConfirmation.isIdenticalTo(password)
        eMailConfirmation.isIdenticalTo(eMail)

        btnValidateForm.setOnClickListener {

            resultat.setText("La validation du formulaire est vu dans un autre article")

        }
    }
}

 

6.2 – Le Layout
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
            xmlns:tools="http://schemas.android.com/tools"
            xmlns:app="http://schemas.android.com/apk/res-auto"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:layout_marginTop="1dp"
>

    <LinearLayout android:layout_width="match_parent"
                  android:layout_height="match_parent"
                  android:orientation="vertical"
    >

        <fr.sebastienlaunay.formulaire.PersoTextInputLayout
                android:id="@+id/landLinePhone"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_margin="1dp"
                tools:layout_height="30dp"
                tools:background="@color/colorPrimary"

                app:input_type="landline_phone"
                app:hide_valid_indicator="true"
        />

        <fr.sebastienlaunay.formulaire.PersoTextInputLayout
                android:id="@+id/mobilePhone"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_margin="1dp"
                tools:layout_height="30dp"
                tools:background="@color/colorPrimary"

                app:input_type="mobile_phone"
                app:hide_valid_indicator="false"
                app:edit_text_hint="Téléphone Mobile - hint personnalisé à partir du xml"
        />

      // [...]

      </LinearLayout>
</ScrollView>

 

6.3 – La classe PersoTextInputLayout
package fr.sebastienlaunay.formulaire

import android.app.DatePickerDialog
import android.content.Context
import android.support.design.widget.TextInputEditText
import android.support.design.widget.TextInputLayout
import android.text.InputType
import android.util.AttributeSet
import android.view.inputmethod.EditorInfo
import android.text.InputFilter
import android.util.Log
import android.view.View
import fr.sebastienlaunay.data.Constants
import org.joda.time.DateTime
import org.joda.time.Days
import org.joda.time.format.DateTimeFormat
import java.text.SimpleDateFormat
import java.util.*

@Suppress("DEPRECATION")
class PersoTextInputLayout : TextInputLayout, UField {

    enum class Type(val inputType: Int = -1, val hint: Int = -1) {
        FirstName(0, R.string.common_field_first_name_hint),
        LastName(1, R.string.common_field_last_name_hint),
        Email(2, R.string.common_field_email_hint),
        EmailConfirmation(3, R.string.common_field_email_confirmation_hint),
        Address(4, R.string.common_field_address_address_hint),
        PostalCode(5, R.string.common_field_postal_code_hint),
        City(6, R.string.common_field_city_hint),
        Phone(7, R.string.common_field_phone_hint),
        MobilePhone(8, R.string.common_field_mobile_phone),
        LandlinePhone(9, R.string.common_field_land_line_phone),
        BirthDay(10, R.string.common_field_birthday_hint),
        Password(11, R.string.common_field_password_hint),
        NewPassword(12, R.string.common_field_new_password_hint),
        OldPassword(13, R.string.common_field_old_password_hint),
        ConfirmPassword(14, R.string.common_field_password_confirmation_hint),
        BirthDayOlderThan18(15, R.string.common_field_birthday_hint),
        Text;

        companion object {
            fun from(inputType: Int): Type = Type.values().first { inputType == it.inputType }
        }
    }

    private lateinit var mEditText: TextInputEditText
    private lateinit var mType: Type
    private var mHideValidIndicator: Boolean = false
    private var mExternalValidator: Validator? = null
    private var mDisablePatternValidation = false
    private val mCalendar = Calendar.getInstance(TimeZone.getDefault()) // Utilisé par le date picker

    // Permet de vérifier que le contenu (la valeur) d'un autre PersoTextInputLayout est identique à celui-ci
    private var mReferenceToConfirm: PersoTextInputLayout? = null

    constructor(context: Context) : super(context) {
        init(context, null, 0)
    }

    constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
        init(context, attrs, 0)
    }

    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
        init(context, attrs, defStyleAttr)
    }

    private fun init(context: Context, attrs: AttributeSet?, defStyleAttr: Int) {

        //mEditText = AppCompatEditText(context)
        mEditText = TextInputEditText(context)
        mEditText.maxLines = 1
        mEditText.imeOptions = EditorInfo.IME_ACTION_NEXT

        val a = context.obtainStyledAttributes(attrs, R.styleable.PersoTextInputLayout, defStyleAttr, 0)

        // Récupération de la valeur de app:edit_text_hint défini dans le fichier xml
        val customHint = a.getString(R.styleable.PersoTextInputLayout_edit_text_hint)

        customHint?.let { Log.d("LOGFORM", "customerHint = $it") } ?: run { Log.d("LOGFORM", "customHint non défini") }

        // Récupération du type de champs provenant de app:input_type défini dans le fichier xml
        mType = Type.from(a.getInt(R.styleable.PersoTextInputLayout_input_type, -1))

        if (mType == Type.Text) {
            Log.d("LOGFORM", "app:input_type n'a pas été défini dans le fichier xml - mType.")
        } else {
            Log.d(
                "LOGFORM",
                "app:input_type a été défini dans le fichier xml avec mType.inputType : ${mType.inputType} - mType.hint correspondant est  : ${context.getString(
                    mType.hint
                )} - "
            )
        }

        // Récupération du Style défini au niveau de app:color_style du fichier xml.
        // Si aucun style défini au niveau du xml, utilisation du style PersoEditTextGrey_EditText
        val style = a.getResourceId(R.styleable.PersoTextInputLayout_color_style, R.style.PersoTextInputLayout_EditText)

        mEditText.setTextAppearance(context, style)

        // Récupération de app:hide_valid_indicator défini dans le fichier xml
        // Si celui-ci n'existe pas, prend la valeur false par défaut
        // Cela va permettre d'afficher ou de cacher l'indicateur de validation du champ dans la méthode valide()
        mHideValidIndicator = a.getBoolean(R.styleable.PersoTextInputLayout_hide_valid_indicator, false)

        a.recycle()

        // S'il y a une valeur dans app:edit_text_hint ou s'il y a une valeur dans app:input_type
        // (Dans le cas contraire, la valeur sera celle indiquée dans android:hint défini dans le fichier xml.
        if (customHint != null || mType != Type.Text) {

            // hint = customHint , si il y a une valeur dans customerHint
            // Sinon, hint = la valeur dans mType.hint
            hint = customHint ?: context.getString(mType.hint)

            // Pas sur de l'utilité de mettre un contentDescription a la vue, sachant que ce n'est pas une image
            contentDescription = customHint ?: context.getString(mType.hint)
        }

        mEditText.apply {

            // En fonction du type du champ, on applique l'inputType correspondant.
            // Cela permet, entre autre, d'afficher le clavier correspondant.
            // https://developer.android.com/reference/android/text/InputType
            inputType = when (mType) {
                Type.Email -> InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS
                Type.Phone, Type.MobilePhone, Type.LandlinePhone -> InputType.TYPE_CLASS_PHONE
                Type.Password, Type.ConfirmPassword, Type.OldPassword, Type.NewPassword -> InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
                Type.PostalCode -> InputType.TYPE_CLASS_NUMBER
                Type.BirthDay, Type.BirthDayOlderThan18 -> InputType.TYPE_NULL

                else -> InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_CAP_WORDS
            }

            // Permet d'appliquer des filtres en fonction du type du champ.
            when (mType) {
                // Par exemple, l'edittext pour un type Phone, sera limiter à PHONE_NUMBER_LENGTH caractères
                Type.Phone -> filters = arrayOf(InputFilter.LengthFilter(Constants.InputType.PHONE_NUMBER_LENGTH))
                Type.FirstName, Type.LastName -> filters = arrayOf(InputFilter.LengthFilter(Constants.InputType.MAX_LENGTH))
            }
        }

        // Ajout de l'editText à la vue
        addView(mEditText)

        // Lors que l'on a déjà le focus, c'est le click listener qui est appelé.
        mEditText.setOnClickListener {

            when (mType) {
                Type.BirthDay, Type.BirthDayOlderThan18 -> openBirthdayDatePicker()
            }
        }

        mEditText.setOnFocusChangeListener { _, focus ->

            Log.d("LOGFORM", "FocusChanged - View : $contentDescription - Focus : $focus ")

            validate(!focus)

            if (focus && mType == Type.BirthDay) openBirthdayDatePicker()
            if (focus && mType == Type.BirthDayOlderThan18) openBirthdayDatePicker()
        }
    }

    fun isEmpty(): Boolean = value.isNullOrEmpty()

    var value: String?
        get() = mEditText.text.toString()
        set(value) {
            mEditText.apply {
                setText(value ?: "")
                setSelection(mEditText.text?.length ?: 0)
            }
            validate()
        }

    var dateValue: Date?
        get() = mCalendar.time
        set(date) {
            date?.let {
                mCalendar.time = it
                value = SimpleDateFormat(Constants.InputType.BIRTHDAY_FORMAT, Locale.getDefault()).format(it)
            }
        }

    override fun isValid(): Boolean {

        // Si la vérification est faire en externe
        mExternalValidator?.let {
            // On retourne le résultat de cette vérification externe
            return it.isValid()
        }
        // Sinon, on retourne la vérification interne suivante :
            ?: run {

                return (visibility != View.VISIBLE || !isEnabled)
                        || (!isEmpty()
                        && (mDisablePatternValidation || isValidPattern())
                        && (mReferenceToConfirm == null || isReferenceValidConfirmation())
                        )
            }

        // La vérification interne correspond la logique suivante :
        //
        //        Le Champ PersoTextInputLayout est Valide
        //
        //        SI (
        //            Ce champ n'est pas visible (View.VISIBLE)
        //            OU
        //            Si ce champ n'est pas activé (enabled)
        //        )
        //        OU SI (
        //                Ce champ n'est pas vide (!isEmpty())
        //                ET (
        //                     Si l'on a demandé de ne pas faire de Validation de Pattern (mDisablePatternValidation)
        //                     OU
        //                     Si le Pattern est validé (isValidPattern()
        //                )
        //                ET (
        //                     S'il n'y a pas d'autre référence à confimer (mReferenceToConfirm)
        //                     OU
        //                     Si la Référence est bien confirmée (isReferenceValidConfirmation())
        //                )
        //         )
    }

    private fun openBirthdayDatePicker() {
        Log.d("LOGFORM", "Affichage du date picker")
        DatePickerDialog(
            context,
            R.style.PersoTextInputLayout_DatePickerDialog,

            DatePickerDialog.OnDateSetListener { _, year, monthOfYear, dayOfMonth ->
                mCalendar.set(Calendar.YEAR, year)
                mCalendar.set(Calendar.MONTH, monthOfYear)
                mCalendar.set(Calendar.DAY_OF_MONTH, dayOfMonth)
                dateValue = mCalendar.time
            },
            mCalendar.get(Calendar.YEAR),
            mCalendar.get(Calendar.MONTH),
            mCalendar.get(Calendar.DAY_OF_MONTH)
        ).apply {
            datePicker.maxDate = Date().time
            show()
        }
    }

    // Permet d'effectuer la validation du PersoTextInputLayout
    fun validate(showError: Boolean = true) {

        // Si on ne doit pas afficher l'erreur, on enlève l'icone X ou V
        if (!showError) {
            mEditText.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0)
        }

        // Si on ne doit pas afficher l'erreur, ou si le champ est vide, on indique alors aucune erreur
        if (!showError || isEmpty()) {
            error = null
            isErrorEnabled = false  // Permet de ne pas avoir d'espace vide après l'affichage d'une erreur
            // Besoin d'être défini après un error = null
        } else { // Sinon : On vérifie que le champ est valide
            when {

                // Si le champ n'est pas valide
                !isValid() -> {
                    // On affiche le message d'erreur correspondant au Type du champ
                    error = when (mType) {
                        Type.LandlinePhone -> context.getString(R.string.form_error_landline_phone_format)
                        Type.MobilePhone -> context.getString(R.string.form_error_mobile_phone_format)
                        Type.Email -> context.getString(R.string.form_error_email_format)
                        Type.Phone -> context.getString(R.string.form_error_phone_format)
                        Type.Password, Type.NewPassword, Type.OldPassword -> context.getString(R.string.form_error_password_format)
                        Type.ConfirmPassword -> context.getString(R.string.form_error_confirm_password_format)
                        Type.FirstName, Type.LastName -> context.getString(R.string.form_error_name_format)
                        Type.EmailConfirmation -> context.getString(R.string.form_error_confirm_email_format)
                        Type.PostalCode -> context.getString(R.string.form_error_postal_code)
                        Type.BirthDayOlderThan18 -> context.getString(R.string.form_error_birthday_not_older_than_18)
                        else -> null
                    }

                    if (error == null) isErrorEnabled = false  // Voir ci-dessus la raison de cette ligne

                    // On affiche l'icone X
                    mEditText.setCompoundDrawablesWithIntrinsicBounds(0, 0, R.drawable.ico_formular_field_error, 0)
                }
                // Si le champ est valide
                else -> {
                    // On affiche aucun message d'erreur
                    error = null
                    isErrorEnabled = false

                    // Sous condition que app:hide_valid_indicator n'est pas indiqué à true dans le fichier xml
                    val drawable = when (mHideValidIndicator) {
                        true -> 0
                        false -> R.drawable.ico_formular_field_ok
                    }
                    // on affiche l'icone de validation V
                    mEditText.setCompoundDrawablesWithIntrinsicBounds(0, 0, drawable, 0)
                }
            }
        }
    }

    // Permet de ne faire aucune validation du pattern en interne.
    fun disablePatternValidation(): PersoTextInputLayout {
        mDisablePatternValidation = true
        return this
    }

    // Permet de faire la validation du champ en externe
    fun withExternalValidator(validator: Validator): PersoTextInputLayout {
        mExternalValidator = validator
        return this
    }

    // Permet de vérifier que la valeur d'une référence (d'un PersoTextInputLayout) est équivalente à une autre référence
    fun isIdenticalTo(reference: PersoTextInputLayout): PersoTextInputLayout {
        mReferenceToConfirm = reference
        return this
    }

    private fun isReferenceValidConfirmation(): Boolean = mReferenceToConfirm?.value == this.value

    private fun isValidPattern(): Boolean {

        value?.let { value ->
            when (mType) {
                Type.LandlinePhone -> return Regex(Constants.InputType.LANDLINE_PHONE_PATTERN).matches(value)
                Type.MobilePhone -> return Regex(Constants.InputType.MOBILE_PHONE_PATTERN).matches(value)
                Type.Phone -> return (Regex(Constants.InputType.LANDLINE_PHONE_PATTERN).matches(value)
                    .xor(Regex(Constants.InputType.MOBILE_PHONE_PATTERN).matches(value)))
                Type.Password, Type.NewPassword, Type.OldPassword -> return Regex(Constants.InputType.PASSWORD_PATTERN).matches(
                    value
                )
                Type.FirstName, Type.LastName -> return Regex(Constants.InputType.NAME_PATTERN).matches(value)
                Type.Email -> return Regex(Constants.InputType.EMAIL_PATTERN).matches(value)
                Type.PostalCode -> return Regex(Constants.InputType.CODE_POSTAL_PATTERN).matches(value)
                Type.BirthDayOlderThan18 -> return isUser18Older(value)
                else -> return true
            }
        }
    }

    interface Validator {
        fun isValid(): Boolean
    }

    fun isUser18Older(value: String): Boolean {
        val dtf = DateTimeFormat.forPattern(Constants.InputType.BIRTHDAY_FORMAT)
        val date = dtf.parseDateTime(value)
        val minAge = DateTime()
        val days = Days.daysBetween(date, minAge.minusYears(18))
        return days.getDays() >= 0
    }
}