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_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
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
}
}
