Skip to content

Instantly share code, notes, and snippets.

@ForceTower
Last active April 3, 2023 12:54
Show Gist options
  • Save ForceTower/51706fc183131ee60483b47075772576 to your computer and use it in GitHub Desktop.
Save ForceTower/51706fc183131ee60483b47075772576 to your computer and use it in GitHub Desktop.
private const val ERROR_EMPTY_VALUE = "Mask cannot be empty"
private const val ERROR_INVALID_CHARACTER = "Mask does not contain specified character"
data class Mask(
val value: String,
val character: Char = value.mostOccurred()
) {
init {
require(value.isNotEmpty()) { ERROR_EMPTY_VALUE }
require(value.contains(character)) { ERROR_INVALID_CHARACTER }
}
}
fun String.mostOccurred(): Char {
require(isNotEmpty()) { ERROR_EMPTY_VALUE }
val result = groupBy { it }.maxByOrNull { it.value.size }
return result?.key ?: first()
}
package dev.forcetower.edittextthing
import android.text.Selection
import kotlin.math.max
import kotlin.math.min
class Masker(private val mask: Mask) {
fun apply(text: CharSequence, action: Action): MaskResult {
val source = text.iterator()
val masked = StringBuilder()
var char = source.nextOrNull()
loop@ for (maskChar in mask.value) {
when {
maskChar == char && maskChar != mask.character -> {
masked.append(maskChar)
char = source.nextOrNull()
}
maskChar == mask.character -> {
char = source.nextMaskChar(char)
if (char == null) {
break@loop
} else {
masked.append(char)
char = source.nextOrNull()
}
}
else -> {
if (char == null && action == Action.DELETE) break@loop
masked.append(maskChar)
}
}
}
// Clear mask to let user defined hint shown
var maskedText = masked.toString()
if (mask.value.startsWith(maskedText)) {
maskedText = masked.clear().toString()
}
val selection = nextSelection(text, maskedText, action)
return MaskResult(maskedText, selection)
}
private fun nextSelection(before: CharSequence, after: CharSequence, action: Action): Int {
if (after.isEmpty()) return 0
val start = Selection.getSelectionStart(before)
if (action == Action.DELETE) return start
val end = after.length
var nextMaskIndex = mask.value.indexOf(mask.character, start)
// Make sure the cursor is located to the end of the selection
val previousChar = before.getOrNull(start)
val nextChar = after.getOrNull(nextMaskIndex)
if (previousChar != nextChar && nextChar != mask.character) {
++nextMaskIndex
}
return nextMaskIndex.coerceIn(min(start, end), max(start, end))
}
}
internal fun CharIterator.nextOrNull(): Char? = if (hasNext()) nextChar() else null
internal fun CharIterator.nextMaskChar(char: Char?): Char? {
var nextMaskChar = char ?: return null
while (hasNext()) {
if (nextMaskChar.isLetterOrDigit()) break
nextMaskChar = nextChar()
}
// In case we could not find any valid character
return if (nextMaskChar.isLetterOrDigit()) nextMaskChar else null
}
import android.text.Editable
import android.text.Selection
import android.text.TextWatcher
class DocumentTextWatcherSecond(mask: Mask) : TextWatcher {
private val masker = Masker(mask)
private var changing: Boolean = false
private var result: MaskResult? = null
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
if (changing || s.isNullOrEmpty()) return
val action = if (before > 0 && count == 0) Action.DELETE else Action.INSERT
result = masker.apply(s, action)
}
override fun afterTextChanged(s: Editable?) {
if (changing || s.isNullOrEmpty()) return
changing = true
result?.apply(s)
changing = false
}
}
enum class Action {
INSERT,
DELETE
}
data class MaskResult(
val masked: String,
val nextSelection: Int
) {
fun apply(editable: Editable) {
val filters = editable.filters
editable.filters = emptyArray()
editable.replace(0, editable.length, masked)
Selection.setSelection(editable, nextSelection)
// resume filters
editable.filters = filters
}
}
import android.content.Context
import android.text.TextWatcher
import android.util.AttributeSet
import com.google.android.material.textfield.TextInputEditText
import dev.forcetower.edittextthing.DocumentTextWatcherSecond
class MaskedEditText @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = com.google.android.material.R.attr.editTextStyle
) : TextInputEditText(context, attrs, defStyleAttr) {
private var maskTextWatcher: DocumentTextWatcherSecond? = null
private var maskFormat: String = ""
private var maskCharacter: String = ""
init {
context.obtainStyledAttributes(
attrs,
R.styleable.MaskedEditText,
defStyleAttr,
0
).apply {
maskFormat = getString(R.styleable.MaskedEditText_maskFormat).orEmpty()
maskCharacter = getString(R.styleable.MaskedEditText_maskCharacter).orEmpty()
if (maskFormat.isNotEmpty()) {
val maskChar = if (maskCharacter.isEmpty()) maskFormat.mostOccurred() else maskCharacter.single()
val mask = Mask(maskFormat, maskChar)
maskTextWatcher = DocumentTextWatcherSecond(mask)
}
recycle()
}
}
fun setMaskFormat(value: String) {
val maskChar = if (maskCharacter.isEmpty()) value.mostOccurred() else maskCharacter.single()
val mask = Mask(value, maskChar)
maskTextWatcher = DocumentTextWatcherSecond(mask)
addTextChangedListener(maskTextWatcher)
maskFormat = value
}
fun setMaskCharacter(character: String) {
val maskChar = if (character.isEmpty()) maskFormat.mostOccurred() else character.single()
val mask = Mask(maskFormat, maskChar)
maskTextWatcher = DocumentTextWatcherSecond(mask)
addTextChangedListener(maskTextWatcher)
maskCharacter = character
}
override fun addTextChangedListener(watcher: TextWatcher?) {
if (watcher is DocumentTextWatcherSecond) {
removeTextChangedListener(maskTextWatcher)
maskTextWatcher = watcher
}
super.addTextChangedListener(watcher)
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
maskTextWatcher?.let { addTextChangedListener(it) }
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
removeTextChangedListener(maskTextWatcher)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment