I used the solution of Uche Dim, fixed some issues and cleaned up the code.
So key improvements in my code are:
- if a user tries to enter "13", only "1" will get entered.
- when the user starts entering the year, after he has deleted the slash, a slash will be added so it keeps the format of MM/yy.
All in all this is almost like the Play Store's expiry field of new cards.
I've created a Kotlin class but usage is also added for Java.
CardExpiryTextWatcher class:
class CardExpiryTextWatcher(private val mTextInputLayout: TextInputLayout,
private val mServerDate: Date,
private val mListener: DateListener) : TextWatcher {
private val mExpiryDateFormat = SimpleDateFormat("MM/yy", Locale.US).apply {
isLenient = false
}
private var mLastInput = ""
private var mIgnoreAutoValidationOnce = false
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
}
@SuppressLint("SetTextI18n")
override fun afterTextChanged(s: Editable) {
val input = s.toString()
when (s.length) {
1 -> handleMonthInputForFirstCharacter(input)
2 -> handleMonthInputForSecondCharacter(input)
3 -> addSlashIfNotAddedAtEnd(input)
4 -> addSlashIfNotAddedInMiddle(input)
5 -> validateDateAndCallListener(input)
}
mLastInput = mTextInputLayout.editText!!.text.toString()
}
private fun validateDateAndCallListener(input: String) {
try {
if (mIgnoreAutoValidationOnce) {
mIgnoreAutoValidationOnce = false
return
}
if (input[2] == '/') {
val date = mExpiryDateFormat.parse(input)
validateCardIsNotExpired(date)
}
} catch (e: ParseException) {
mTextInputLayout.error = mTextInputLayout.context.getString(R.string.card_exp_date_error)
}
}
private fun validateCardIsNotExpired(cardExpiry: Date) {
if (DateUtils.isDateBefore(cardExpiry, mServerDate)) {
mTextInputLayout.error = mTextInputLayout.context.getString(R.string.card_expired)
return
}
mListener.onExpiryEntered(cardExpiry)
}
@SuppressLint("SetTextI18n")
private fun addSlashIfNotAddedAtEnd(input: String) {
val lastCharacter = input[input.length - 1]
if (lastCharacter != '/' && !input.startsWith('/')) {
val month = input.substring(0, 2)
mTextInputLayout.editText!!.setText("$month/$lastCharacter")
mTextInputLayout.editText!!.setSelection(mTextInputLayout.editText!!.text.toString().length)
}
}
@SuppressLint("SetTextI18n")
private fun addSlashIfNotAddedInMiddle(input: String) {
if (input.contains('/')) {
return
}
val month = input.substring(0, 2)
val year = input.substring(2, 4)
mIgnoreAutoValidationOnce = true
mTextInputLayout.editText!!.setText("$month/$year")
mTextInputLayout.editText!!.setSelection(2)
}
@SuppressLint("SetTextI18n")
private fun handleMonthInputForSecondCharacter(input: String) {
if (mLastInput.endsWith("/")) {
return
}
val month = Integer.parseInt(input)
if (month > 12) {
mTextInputLayout.editText!!.setText(mLastInput)
mTextInputLayout.editText!!.setSelection(mTextInputLayout.editText!!.text.toString().length)
mTextInputLayout.error = mTextInputLayout.context.getString(R.string.card_exp_date_error)
} else {
mTextInputLayout.editText!!.setText("${mTextInputLayout.editText!!.text}/")
mTextInputLayout.editText!!.setSelection(mTextInputLayout.editText!!.text.toString().length)
}
}
@SuppressLint("SetTextI18n")
private fun handleMonthInputForFirstCharacter(input: String) {
val month = Integer.parseInt(input)
if (month in 2..11) {
mTextInputLayout.editText!!.setText("0${mTextInputLayout.editText!!.text}/")
mTextInputLayout.editText!!.setSelection(mTextInputLayout.editText!!.text.toString().length)
}
}
interface DateListener {
fun onExpiryEntered(date: Date)
}
companion object {
@JvmStatic
fun attachTo(textInputLayout: TextInputLayout, serverDate: Date, listener: DateListener) {
textInputLayout.editText!!.addTextChangedListener(
CardExpiryTextWatcher(textInputLayout, serverDate, listener))
}
}
}
Usage (Kotlin):
CardExpiryTextWatcher.attachTo(inputCardExpiry, mServerDate, object : CardExpiryTextWatcher.DateListener {
override fun onExpiryEntered(date: Date) {
// TODO implement your handling
}
})
Usage (Java):
CardExpiryTextWatcher.attachTo(inputCardExpiry, mServerDate, new CardExpiryTextWatcher.DateListener() {
@Override
public void onExpiryEntered(@NonNull Date date) {
// TODO implement your handling
}
});
Caveat:
The date will always be 2 digit long (e.g. December 4 will be 04/12, not 4/12) but if the user removes one digit from the date, it can become 4/12 so you need to run following method before validation:
/**
* Makes sure that the date's day is of 2 digits, (e.g. 4/12 will be converted to 04/12)
* */
fun normalizeExpiryDate(expiryDate: String): String {
if (expiryDate.length == 4 && expiryDate.indexOf('/') == 1) {
return "0$expiryDate"
}
return expiryDate
}
Note: inputCardExpiry
is the InputTextLayout
which contains the EditText.