Ever wonder, how to make the text inside TextInputLayout censored and/or partially censored? But the plot twist is, how can you censor the text without putting a “password” property on its inputType to accomplish this task?
How about setting it as a censored text first, then putting it in TextInputLayout? It could work, but it also needs extra steps to uncensor to its original when the validation process is executed. Then, how? Well, this is the story.
Long story short: there is a new feature designed to protect user-sensitive data. The feature will hide the user’s personal information so that when certain pages contain email addresses, id card numbers, full names, or phone numbers, the content of each will be censored. In this case, those pages are profile form page and profile view page. On the profile form page, the user fills in their personal information that will be submitted later.
The requirement is to censor the text inside the input field (TextInputLayout) with the asterisk symbol (∗) while/after submitting the data. Even when there is already existing data from response callback into that field, its data must also be censored. Each field type (email, name, id card, phone, etc.) has its own censoring type and rule.
To visualize it, imagine a user opening the profile form page. It has some fields of TextInputLayout that need to be filled. Let’s say, at that time, a user fills in the full name field. In that case, the field will be censored after finished. The password field inspires the whole project. It looks like we create a password but with some adjustments and behavior differences.
TransformationMethod; It is
Because we originally wanted to mimic how the password field works, we explored TextInputLayout source code. The source code can be found in the Android material module .jar or in this repository. Without wasting time, we immediately search “password” in that file. The findings lead us to the end icon toggle functionality, called END_ICON_PASSWORD_TOGGLE.
https://github.com/material-components/material-components-android/blob/174a57dabeb6e0481993b71af71e8aaa8afbb68d/lib/java/com/google/android/material/textfield/TextInputLayout.java#L3488C1-L3500C4
/**
* Set up the end icon mode. When set, a button is placed at the end of the EditText which enables
* the user to perform the specific icon's functionality.
*
* @param endIconMode the end icon mode to be set: {@link #END_ICON_PASSWORD_TOGGLE}, {@link
* #END_ICON_CLEAR_TEXT}, or {@link #END_ICON_CUSTOM}; or {@link #END_ICON_NONE} to clear the
* current icon if any
* @attr ref com.google.android.material.R.styleable#TextInputLayout_endIconMode
*/
public void setEndIconMode(@EndIconMode int endIconMode) {
endLayout.setEndIconMode(endIconMode);
}
TL;DR: The icon mode’s purpose is to show a password toggle if its EditText displays a password. It is set into endLayout (EndCompoundLayout) which has EndIconDelegates property inside. This delegate object contains a selection of other delegates, including PasswordToggleEndIconDelegate. We are getting close!
https://github.com/material-components/material-components-android/blob/174a57dabeb6e0481993b71af71e8aaa8afbb68d/lib/java/com/google/android/material/textfield/EndCompoundLayout.java#L349C1-L352C4
EndIconDelegate getEndIconDelegate() {
return endIconDelegates.get(endIconMode);
}
https://github.com/material-components/material-components-android/blob/174a57dabeb6e0481993b71af71e8aaa8afbb68d/lib/java/com/google/android/material/textfield/EndCompoundLayout.java#L358C1-L388C4
void setEndIconMode(@EndIconMode int endIconMode) {
if (this.endIconMode == endIconMode) {
return;
}
tearDownDelegate(getEndIconDelegate());
int previousEndIconMode = this.endIconMode;
this.endIconMode = endIconMode;
dispatchOnEndIconChanged(previousEndIconMode);
setEndIconVisible(endIconMode != END_ICON_NONE);
EndIconDelegate delegate = getEndIconDelegate();
setEndIconDrawable(getIconResId(delegate));
setEndIconContentDescription(delegate.getIconContentDescriptionResId());
setEndIconCheckable(delegate.isIconCheckable());
if (delegate.isBoxBackgroundModeSupported(textInputLayout.getBoxBackgroundMode())) {
setUpDelegate(delegate);
} else {
throw new IllegalStateException(
"The current box background mode "
+ textInputLayout.getBoxBackgroundMode()
+ " is not supported by the end icon mode "
+ endIconMode);
}
setEndIconOnClickListener(delegate.getOnIconClickListener());
if (editText != null) {
delegate.onEditTextAttached(editText);
setOnFocusChangeListenersIfNeeded(delegate);
}
applyIconTint(textInputLayout, endIconView, endIconTintList, endIconTintMode);
refreshIconState(/* force= */ true);
}
https://github.com/material-components/material-components-android/blob/174a57dabeb6e0481993b71af71e8aaa8afbb68d/lib/java/com/google/android/material/textfield/EndCompoundLayout.java#L826C1-L851C6
EndIconDelegate get(@EndIconMode int endIconMode) {
EndIconDelegate delegate = delegates.get(endIconMode);
if (delegate == null) {
delegate = create(endIconMode);
delegates.append(endIconMode, delegate);
}
return delegate;
}
private EndIconDelegate create(@EndIconMode int endIconMode) {
switch (endIconMode) {
case END_ICON_PASSWORD_TOGGLE:
return new PasswordToggleEndIconDelegate(endLayout, passwordIconDrawableId);
case END_ICON_CLEAR_TEXT:
return new ClearTextEndIconDelegate(endLayout);
case END_ICON_DROPDOWN_MENU:
return new DropdownMenuEndIconDelegate(endLayout);
case END_ICON_CUSTOM:
return new CustomEndIconDelegate(endLayout);
case END_ICON_NONE:
return new NoEndIconDelegate(endLayout);
default:
throw new IllegalArgumentException("Invalid end icon mode: " + endIconMode);
}
}
Now we know that END_ICON_PASSWORD_TOGGLE will return a delegate object, PasswordToggleEndIconDelegate. Thus, that object contains properties. For example, it contains OnClickListener. This listener is called to work within the toggle icon click listener, setEndIconOnClickListener(). This indicates that whenever the toggle is clicked, it will run the block codes inside.
https://github.com/material-components/material-components-android/blob/174a57dabeb6e0481993b71af71e8aaa8afbb68d/lib/java/com/google/android/material/textfield/PasswordToggleEndIconDelegate.java#L36C1-L53C5
private final OnClickListener onIconClickListener = view -> {
if (editText == null) {
return;
}
// Store the current cursor position
final int selection = editText.getSelectionEnd();
if (hasPasswordTransformation()) {
editText.setTransformationMethod(null);
} else {
editText.setTransformationMethod(PasswordTransformationMethod.getInstance());
}
// And restore the cursor position
if (selection >= 0) {
editText.setSelection(selection);
}
refreshIconState();
};
Finally, the block codes themselves set a TransformationMethod into EditText (correspondence with TextInputLayout, of course). Wait, what is that? PasswordTransformationMethod? Well, turns out it is a text transformer. It is used to do things like replace the characters of the source with other characters (quoted from this). In this case, it changes the password’s characters into dots. That is what we want to work with, the TransformationMethod.
Mimic PasswordTransformationMethod
We know for sure that it changes the characters of passwords into dots. Theoretically, it can change certain characters inside the input field into an asterisk symbol, as we planned. Let’s learn the structure of this PasswordTransformationMethod first. Again, the source can be accessed here.
https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/text/method/PasswordTransformationMethod.java
public class PasswordTransformationMethod
implements TransformationMethod, TextWatcher
{
public CharSequence getTransformation(CharSequence source, View view) {
if (source instanceof Spannable) {
Spannable sp = (Spannable) source;
/*
* Remove any references to other views that may still be
* attached. This will happen when you flip the screen
* while a password field is showing; there will still
* be references to the old EditText in the text.
*/
ViewReference[] vr = sp.getSpans(0, sp.length(),
ViewReference.class);
for (int i = 0; i < vr.length; i++) {
sp.removeSpan(vr[i]);
}
removeVisibleSpans(sp);
sp.setSpan(new ViewReference(view), 0, 0,
Spannable.SPAN_POINT_POINT);
}
return new PasswordCharSequence(source);
}
...
After reading it carefully, the part that has an important role is getTransformation(). Compared to other methods available there, it is the only method that actually does something with the text (source), though. This method returns a new CharSequence from the original source. In this case, it returns the PasswordCharSequence. This PasswordCharSequence is where the magic takes place.
https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/text/method/PasswordTransformationMethod.java
...
private static class PasswordCharSequence
implements CharSequence, GetChars
{
...
public char charAt(int i) {
if (mSource instanceof Spanned) {
Spanned sp = (Spanned) mSource;
int st = sp.getSpanStart(TextKeyListener.ACTIVE);
int en = sp.getSpanEnd(TextKeyListener.ACTIVE);
if (i >= st && i < en) {
return mSource.charAt(i);
}
Visible[] visible = sp.getSpans(0, sp.length(), Visible.class);
for (int a = 0; a < visible.length; a++) {
if (sp.getSpanStart(visible[a].mTransformer) >= 0) {
st = sp.getSpanStart(visible[a]);
en = sp.getSpanEnd(visible[a]);
if (i >= st && i < en) {
return mSource.charAt(i);
}
}
}
}
return DOT;
}
...
PasswordCharSequence and the implementation of GetChars are part of CharSequence, but let’s just focus on CharSequence. This class generates a sequence of chars (char type values). At the charAt() method, it returns a char at a specific index, in this case, a dot.
How about other methods? Are they not important at all? Other methods or block codes inside a PasswordTransformationMethod or PasswordCharSequence can be ignored because they are handling the spannable version of the source when copying or pasting from other input fields. It is just some “cleansing” procedure, IMO. All right, now we may start working on our own TransformationMethod. Let’s get started.
Our Own TransformationMethod
To start, let’s define all the censor types and their rules. To make it as simple as possible, the requirement only needs to censor certain characters in the middle of words.
For example, to censor the “name” field, it will only show two characters at the prefix and one character at the suffix. The remaining characters will become asterisks, so if the text is “Fatah,” then the result will be “Fa∗∗h.” If the text length does not fit the rule, it can be done by censoring it all the way or not at all. Now, we can make it like this, knowing it can be changed later. Let’s call it CensorType.
enum class CensorType(
val minLength: Int,
val startPeek: Int,
val endPeek: Int
) {
DEFAULT(minLength = 0, startPeek = 0, endPeek = 0),
NAME(minLength = 5, startPeek = 2, endPeek = 1),
PHONE(minLength = 6, startPeek = 3, endPeek = 1),
ID_NUMBER(minLength = 6, startPeek = 2, endPeek = 2),
EMAIL(minLength = 5, startPeek = 1, endPeek = 1)
}
Next is the main event, the TransformationMethod. Create a new file of TransformationMethod. It will only receive basic properties such as CensorType and its own properties — a Char type represents the symbol replacement and a Boolean flag to enable censoring. The flag will be used to activate or deactivate the censor later.
class DataCensorTransformationMethod(
private val censorType: CensorType,
private val minLengthRule: Int = censorType.minLength,
private val startPeek: Int = censorType.startPeek,
private val endPeek: Int = censorType.endPeek,
private val censorSymbol: Char = SYMBOL,
private val censored: Boolean = true
) : TransformationMethod {
...
Properties such as minLengthRule, startPeek and endPeek are declared alongside its CensorType. This is intended for any customization that may occur in later development. Then, implement all the rest methods. The first method is getTransformation(); its role is to return the censored version of CharSequence.
Important to note: it says, “… the returned text must mirror it dynamically instead of doing a one-time copy.” You can read more at this link.
class DataCensorTransformationMethod(
...
) : TransformationMethod {
override fun getTransformation(
source: CharSequence,
view: View?
): CharSequence {
return CensoredCharSequence(source)
}
override fun onFocusChanged(
view: View?,
sourceText: CharSequence?,
focused: Boolean,
direction: Int,
previouslyFocusedRect: Rect?
) {
...
}
...
private inner class CensoredCharSequence(
private val source: CharSequence
) : CharSequence {
override val length: Int
get() = source.length
override fun get(index: Int): Char {
when (censorType) {
...
}
}
override fun subSequence(startIndex: Int, endIndex: Int): CharSequence {
return source.subSequence(startIndex, endIndex)
}
}
...
The censoring process takes place in this class, CensoredCharSequence, specifically inside the get() method. It is similar to charAt in the Java version, though.
So, the code will work like this: read the censor type, validate the index if it complies with the rule return censorSymbol, and is also applicable when the length is not fit. Otherwise, return the original character at that index. Simple as that. First, let’s develop CensorType.NAME as an example. Here’s the code:
...
override fun get(index: Int): Char {
when (censorType) {
CensorType.NAME -> return if (length < minLengthRule || index in
startPeek until (length - endPeek)
) {
censorSymbol
} else {
source[index] // Original char
}
else -> return censorSymbol
}
}
...
The important part has been completed at this stage, and then our TransformationMethod can finally be used. We just need to set it into the input field to show the result.
textInputLayout?.editText?.transformationMethod = DataCensorTransformationMethod(CensorType.NAME)
It works, but anyone would be annoyed by not being able to see what they typed. So, now it’s time for the other TransformationMethod method to jump into action. Let’s get into onFocusChanged(). This method will be triggered when the input field loses or gains its focus (technically, when the user clicks another field or reselects the previous field). When the user selects the input field, it must switch to uncensored mode. This time, we will be using the censored property.
data class DataCensorTransformationMethod(
...
private val censored: Boolean = true
) : TransformationMethod {
override fun getTransformation(
source: CharSequence,
view: View?
): CharSequence {
return if (censored) {
CensoredCharSequence(source)
} else {
UncensoredCharSequence(source)
}
}
override fun onFocusChanged(
view: View?,
sourceText: CharSequence?,
focused: Boolean,
direction: Int,
previouslyFocusedRect: Rect?
) {
val editText = view as? EditText
val transformer = editText?.transformationMethod
if (transformer is DataCensorTransformationMethod) {
if (focused) {
editText.transformationMethod =
transformer.copy(censored = false)
} else {
editText.transformationMethod =
transformer.copy(censored = true)
}
}
}
...
...
private inner class UncensoredCharSequence(private val source: CharSequence) :
CharSequence {
...
override fun get(index: Int): Char {
return source[index]
}
...
So, now, let’s go inside onFocusChanged(). When focused is true, the uncensored mode will be activated by copying the current transformation method. This copying process is executed by calling copy(), a part of the data class in Kotlin. So, we need to change DataCensorTransformationMethod into a data class. Thus, censored property can be changed to false directly. Otherwise, it will be changed to true. By doing so, this input field will automatically change its mode.
Now, what is the UncensoredCharSequence? It is just a stupid class of CharSequence that is similar to CensoredCharSequence, but only returns the original text itself. Just look at the get() method above. IMO, we can return the source in getTransformation() first (in this case, I’m trying to dynamically mirror it as the best practice says. To be honest, I could be wrong, though). Still, the results could be great.
As you can see, when the input text field is selected, it will be uncensored. After that, when it changes to another field, it goes back to being censored again. Looks cool. With this basic technique, we can achieve more advanced censoring types and rules. For instance, look at this:
Conclusion
This tool, TransformationMethod, can be used in many scenarios that require content transformation. It has been a great journey to deep dive into the source code and find what we were looking for. Later, we can get a better understanding of how the password field works, especially how TextInputLayout also works.
Well, that’s it. I hope you all like it. Thanks for reading until the end. Bye 👋
TextInputLayout Censoring With Password-Like Method was originally published in Better Programming on Medium, where people are continuing the conversation by highlighting and responding to this story.