Last active
April 3, 2023 12:54
-
-
Save ForceTower/51706fc183131ee60483b47075772576 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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