4

I faced the following problem: I need to implement the solution for the case when the phone should be entered to the EditText. This phone should have non-removable part and last four numbers should be filled with underscore at the beginning and then when user typing them underscores should be changed to numbers, like:

+12345____ -> typing 6 -> +123456___

I implemented the non-removable part. Here's the way how I did it:

binding.etPhoneNumber.filters = arrayOf(InputFilter.LengthFilter(args.phoneNumber?.length ?: 0))

binding.etPhoneNumber.doAfterTextChanged {
            val symbolsLeft = it?.toString()?.length ?: 0
            if (symbolsLeft < phoneNumberUi.length) {
                binding.etPhoneNumber.setText(phoneNumberUi)
                binding.etPhoneNumber.setSelection(symbolsLeft + 1)
            }
        }

But now I do not understand, how to handle the logic with underscores. I tried to append the underscores in doAfterTextChanged, like if ((args.phoneNumber?.length ?: 0) > (it?.toString()?.length ?: 0)) append n underscores, where n is the difference between length, but in this case I cannot add new symbols as EditText is filled and underscores are not removed. So, how can I solve the problem? Thanks in advance for any help!

Zain
  • 37,492
  • 7
  • 60
  • 84
Sergei Mikhailovskii
  • 2,100
  • 2
  • 21
  • 43
  • I think you in your case, you can separate the part that is non editable from the editable. It doesn't make sense to invoke callback on the part that never changes – Dennis Nguyen Jun 18 '21 at 16:45
  • @PhúcNguyễn thanks for the idea, but I still don't understand how it can be implemented – Sergei Mikhailovskii Jun 20 '21 at 12:52
  • The part +12345 will not change, right? You could make that a TextView and EditText on the part that changes. After user finished, you concatnate the two into one to get the information you need – Dennis Nguyen Jun 20 '21 at 13:09
  • @PhúcNguyễn oh, yes, my question was a bit incorrect. I do not understand how it can help me to solve the problem with replacing underscores – Sergei Mikhailovskii Jun 20 '21 at 13:15
  • hey, I have thought about it, search around a found some thing. I dont know about kotlin but in java, TextWatcher has a method `afterTextChange(Editable editable)`, you can use the editable to update the underscore. I think when you insert a character, you can call the method to delete the underscore. Try reading this [SO](https://stackoverflow.com/a/36859600/12165242) for getting more idea – Dennis Nguyen Jun 20 '21 at 16:42
  • @PhúcNguyễn yes, I thought about it, but the problem is that when edittext is filled with underscores, like `+12345____` and `maxLength` is 10 `afterTextChanged` is not called – Sergei Mikhailovskii Jun 20 '21 at 19:07
  • Have you checked this library: https://github.com/AliAzaz/Edittext-Picker – Ali Azaz Alam Jun 28 '21 at 08:24

3 Answers3

1

You can remove the LengthFilter and check the length in doAfterTextChanged :

    val phoneNumberUi = "+12345"
    val length = 10

    binding.etPhoneNumber.doAfterTextChanged {
        when {
            it == null -> {
            }
            // missing / incomplete prefix
            it.length < phoneNumberUi.length -> {
                it.replace(0, it.length, phoneNumberUi)
            }
            // prefix was edited
            !it.startsWith(phoneNumberUi) -> {
                it.replace(0, phoneNumberUi.length, phoneNumberUi)
            }
            // too short
            it.length < length -> {
                it.append("_".repeat(length - it.length))
            }
            // too long
            it.length > length -> {
                it.replace(length, it.length, "")
            }
            // set the cursor at the first _
            it.indexOf("_") >= 0 -> {
                binding.etPhoneNumber.setSelection(it.indexOf("_"))
            }
        }
    }

Note : This uses a when because every change triggers immediately a recursive call to doAfterTextChanged

bwt
  • 17,292
  • 1
  • 42
  • 60
1

This approach has the following conditional branches

  • The place where the user adds their input (non-removable part or the changeable part)
  • The entered char (number or backspace)

And works by getting the entered char (number/backspace) within the onTextChanged() and its index (second parameter), and set the new EditText value upon the values of both of them.

Also the value of the EditText is tracked by currentText variable. So that we can only replace one character at a time which is the input done by the user to avoid the burden of manipulating the entire text.

You can find the rest of the explanation by the below comments through the code:

attachTextWatcher(findViewById(R.id.edittext))

fun attachTextWatcher(editText: EditText) {

    // set the cursor to the first underscore
    editText.setSelection(editText.text.indexOf("_"))

    var currentText = editText.text.toString() // which is "+12345____"

    val watcher: TextWatcher = object : TextWatcher {

        override fun onTextChanged(
            s: CharSequence,
            newCharIndex: Int, // "newCharIndex" is the index of the new entered char
            before: Int,
            count: Int
        ) {

            // New entered char by the user that triggers the TextWatcher callbacks
            val newChar = s.subSequence(newCharIndex, newCharIndex + count).toString().trim()

            /* Stop the listener in order to programmatically
            change the EditText Without triggering the TextWatcher*/
            editText.removeTextChangedListener(this)

            // Setting the new text of the EditText upon examining the user input
            currentText =
                if (newChar.isEmpty()) { // User entered backspace to delete a char
                    if (newCharIndex in 0..5) { // The backspace is pressed in the non-removable part
                        "+12345" + currentText.substring(6)

                    } else { // The backspace is pressed in the changeable part
                        val sb = StringBuilder(currentText)
                        // replace the the number at which backspace pressed with underscore
                        sb.setCharAt(newCharIndex, '_')
                        sb.toString()
                    }

                } else { // User entered a number
                    if (newCharIndex in 0..5) { // The number is entered in the non-removable part
                        // replace the first underscore with the entered number
                        val sb = StringBuilder(currentText)
                        sb.setCharAt(sb.indexOf("_"), newChar[0])
                        sb.toString()

                    } else { // The number is entered in the changeable part
                        if (newCharIndex < 10) { // Avoid ArrayOutOfBoundsException as the number length should not exceed 10
                            val sb = StringBuilder(currentText)
                            // replace the the number at which the number is entered with the new number
                            sb.setCharAt(newCharIndex, newChar[0])
                            sb.toString()
                        } else currentText
                    }
                }

            // Set the adjusted text to the EditText
            editText.setText(currentText)

            // Set the current cursor place
            if (editText.text.contains("_"))
                editText.setSelection(editText.text.indexOf("_"))
            else
                editText.setSelection(editText.text.length)

            // Re-add the listener, so that the EditText can intercept the number by the user
            editText.addTextChangedListener(this)
        }

        override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {
        }

        override fun afterTextChanged(s: Editable) {
        }
    }

    editText.addTextChangedListener(watcher)
}

This is the layout I'm testing with:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <EditText
        android:id="@+id/edittext"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:inputType="number"
        android:maxLength="11"
        android:text="+12345____"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

Note: make sure to survive the value of the currentText on configuration change.

preview

Zain
  • 37,492
  • 7
  • 60
  • 84
1

I think that PhúcNguyễn had a good idea to marry a TextView with an EditText to produce what you are looking for. You could place these as separate fields in a layout or place them in a composite view. Either way the effect will be the same and you could achieve what you want.

You have already figured out how to handle the static text at the beginning of the field. What I am presenting below is how to handle the underscores so characters that are entered appear to overwrite the underscores.

For the demo, I have place a TextView with the static text beside a custom EditText. It is the custom EditText that is really of any interest. With the custom view, the onDraw() function is overridden to write the underscores as part of the background. Although these underscores will appear like any other character in the field, they cannot be selected, deleted, skipped over or manipulated in any way except, as the user types, the underscores are overwritten one-by-one. The end padding of the custom view is manipulated to provide room for the underscores and text.

enter image description here

Here is the custom view:

EditTextFillInBlanks.kt

class EditTextFillInBlanks @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : androidx.appcompat.widget.AppCompatEditText(context, attrs, defStyleAttr) {

    // Right padding before we manipulate it
    private var mBaseRightPadding = 0

    // Width of text that has been entered
    private var mTextWidth = 0f

    // Mad length of data that can be entered in characters
    private var mMaxLength = 0

    // The blanks (underscores) that we will show
    private lateinit var mBlanks: String

    // MeasureSpec for measuring width of entered characters.
    private val mUnspecifiedWidthHeight = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)

    init {
        mBaseRightPadding = paddingRight
        doOnTextChanged { text, _, _, _ ->
            measure(mUnspecifiedWidthHeight, mUnspecifiedWidthHeight)
            mTextWidth = measuredWidth.toFloat() - paddingStart - paddingEnd
            updatePaddingForBlanks(text)
        }
        setText("", BufferType.EDITABLE)
    }

    /*
        Make sure that the end padding is sufficient to hold the blanks that we are showing.
        The blanks (underscores) are written into the expanded padding.
     */
    private fun updatePaddingForBlanks(text: CharSequence?) {
        if (mMaxLength <= 0) {
            mMaxLength = determineMaxLen()
            check(mMaxLength > 0) { "Maximum length must be > 0" }
        }
        text?.apply {
            val blanksCount = max(0, mMaxLength - length)
            mBlanks = "_".repeat(blanksCount).apply {
                updatePadding(right = mBaseRightPadding + paint.measureText(this).toInt())
            }
        }
    }

    /*
        Draw the underscores on the canvas. They will appear as characters in the field but
        cannot be manipulated by the user.
     */
    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)
        if (mBlanks.isNotEmpty()) {
            canvas?.withSave {
                drawText(mBlanks, paddingStart + mTextWidth, baseline.toFloat(), paint)
            }
        }
    }

    fun setMaxLen(maxLen: Int) {
        mMaxLength = maxLen
    }

    private fun determineMaxLen(): Int {
        // Before Lollipop, we can't get max for InputFilter.LengthFilter
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) return 0

        return filters.firstOrNull { it is InputFilter.LengthFilter }
            ?.let {
                it as InputFilter.LengthFilter
                it.max
            } ?: 0
    }
}

activity_main.xml

<androidx.constraintlayout.widget.ConstraintLayout 
    android:id="@+id/layout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@android:color/holo_blue_light"
    tools:context=".MainActivity">

    <TextView
        android:id="@+id/textView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="@android:color/white"
        android:paddingStart="8dp"
        android:paddingTop="8dp"
        android:text="+12345"
        android:textColor="@android:color/black"
        android:textSize="36sp"
        app:layout_constraintBaseline_toBaselineOf="@id/editableSuffix"
        app:layout_constraintEnd_toStartOf="@+id/editableSuffix"
        app:layout_constraintHorizontal_bias="0.0"
        app:layout_constraintHorizontal_chainStyle="packed"
        app:layout_constraintStart_toStartOf="@+id/guideline2" />

    <com.example.edittextwithblanks.EditTextFillInBlanks
        android:id="@+id/editableSuffix"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="@drawable/edittext_background"
        android:inputType="number"
        android:maxLength="@integer/blankFillLen"
        android:paddingTop="8dp"
        android:paddingEnd="8dp"
        android:textColor="@android:color/black"
        android:textSize="36sp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.5"
        app:layout_constraintStart_toEndOf="@+id/textView"
        app:layout_constraintTop_toTopOf="parent"
        tools:text="____">

        <requestFocus />
    </com.example.edittextwithblanks.EditTextFillInBlanks>

    <androidx.constraintlayout.widget.Guideline
        android:id="@+id/guideline2"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        app:layout_constraintGuide_begin="92dp" />

</androidx.constraintlayout.widget.ConstraintLayout>

MainActivity.kt

class MainActivity : AppCompatActivity() {
    private val mStaticStart = "+12345"

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

        if (BuildConfig.VERSION_CODE < Build.VERSION_CODES.P) {
            val maxLen = resources.getInteger(R.integer.blankFillLen)
            findViewById<EditTextFillInBlanks>(R.id.editableSuffix).setMaxLen(maxLen)
        }
    }
}

It is likely that you can incorporate your static text handling into the custom view for a complete solution.

Cheticamp
  • 61,413
  • 10
  • 78
  • 131