androidxmlandroid-drawablexml-drawable

Custom passwordToggleDrawable is too large in TextInputLayout


I have used android.support.design.widget.TextInputLayout to make a password input that allows the user to toggle readability on the password. The xml is as follows:

<android.support.design.widget.TextInputLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:hintEnabled="false"
    app:passwordToggleDrawable="@drawable/password_toggle_selector"
    app:passwordToggleEnabled="true" >

    <android.support.design.widget.TextInputEditText
        android:id="@+id/password"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:hint="Password"
        android:inputType="textPassword"/>
</android.support.design.widget.TextInputLayout>

The drawable selector is as described by How to customize android passwordToggleDrawable

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:drawable="@drawable/password_toggle_show" 
          android:state_checked="true"/>
    <item android:drawable="@drawable/password_toggle_hide"/>
</selector>

The issue is that the custom drawable becomes really large. Not larger than the edittext, but rather it seems to maximize its size while still fitting inside it (so, it seems to be bounded by the height of the element). However, if I leave the passwordToggleDrawable property unset, the drawable for the toggle is sized as is normal for android (I am sure you have seen the icon in other apps before). After much searching I have found a way to resize the custom one, but I am not happy with how its done (requires 2 extra xml files per drawable) and it only works for API 23+.

I have tried setting the padding of the EditText as the source of TextInputLayout says that it gets the four paddings from it and apply to the mPasswordToggleView (line 1143), but it made no change on the icon and (as expected) also affected the padding of the EditText. I have tried setting minheight to 0. I have also tried changing between EditText and TextInputEditText (using the latter now as it seems to be recommended). I have tried switching the layout_height properties to wrap_content. I have tried scaling the drawable using xml's <scale> tag with the scale properties set. I have tried similarly with the <inset> tag. But none of those methods works.

The way I found (and am currently using) to resize the drawable that actually works is by using the xml tag <layer-list>, while setting the width and height properties. Then the <selector> xml file references those resized drawables instead of the png ones. But I don't like this solution because as I mentioned it requires API 23 and because of that results in a total of 4 extra xml files. It also sets the width and height by themselves, instead of keeping the ratio locked.

<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
    <item
        android:drawable="@drawable/password_toggle_hide"
        android:width="22dp"
        android:height="15dp"/>
</layer-list>

TL;DR How do I set the size of a custom passwordToggleDrawable in TextInputLayout? Preferably to same size as the default drawable.


Solution

  • I were unable to find any solution to the question I actually asked, but I decided to instead solve the issue by disregarding the "in InputTextLayout" part of the question and implemented my own version of the class.

    Mostly it is just a copy of InputTextLayout (sadly that class doesnt translate well for subclassing as everything is private) but with most of the stuff I dont need removed, and more importantly, with the CheckableImageButton mPasswordToggleView changed to a ViewGroup containing a View.

    The ViewGroup is the clickable button, and handles setMinimumDimensions to keep the clickable area at min 48 dp, like the original did through design_text_input_password_icon.xml. This also makes small drawables not hug the right side of the screen as they are centered in the clickable area, giving the margin that the default drawable appears to have.

    The View (or more precisely, a new subclass of it I called CheckableView) is the actual drawable (setBackground()), replacing the CheckableImageButton as the container of the drawable that lets it switch based on state_checked selector.

    The xml-property passwordToggleSize allows a dimension to be set, which is used to scale the drawable. I opted to only have one value instead of width&height, and the drawable scales with its ratio locked such that its greatest dimension matches the dimension specified. I made the default size 24dp, as is specified for the default-drawable in design_ic_visibility.xml.

    PasswordToggleLayout.java:

    import android.content.Context;
    import android.content.res.ColorStateList;
    import android.content.res.TypedArray;
    import android.graphics.drawable.ColorDrawable;
    import android.graphics.drawable.Drawable;
    import android.os.Parcel;
    import android.os.Parcelable;
    import android.support.annotation.Nullable;
    import android.support.v4.graphics.drawable.DrawableCompat;
    import android.support.v4.view.AbsSavedState;
    import android.support.v4.view.ViewCompat;
    import android.support.v4.widget.TextViewCompat;
    import android.text.method.PasswordTransformationMethod;
    import android.util.AttributeSet;
    import android.util.TypedValue;
    import android.view.Gravity;
    import android.view.View;
    import android.view.ViewGroup;
    import android.widget.EditText;
    import android.widget.FrameLayout;
    import android.widget.LinearLayout;
    
    import com.mylifediary.android.client.R;
    
    public class PasswordToggleLayout extends LinearLayout {
    
        // Default values from InputTextLayout's drawable and inflated layout
        final int BUTTON_MIN_SIZE = 48; // The button is 48 dp at minimum.
        final int DEFAULT_DRAWABLE_SIZE = 24; // The default drawable is 24 dp.
    
        int mButtonMinSize;
    
        final FrameLayout mInputFrame;
        EditText mEditText;
    
        private boolean mPasswordToggleEnabled;
        private Drawable mPasswordToggleDrawable;
        private CharSequence mPasswordToggleContentDesc;
        ViewGroup mPasswordToggleViewGroup;
        CheckableView mPasswordToggleView;
        private boolean mPasswordToggledVisible;
        private int mPasswordToggleSize;
        private Drawable mPasswordToggleDummyDrawable;
        private Drawable mOriginalEditTextEndDrawable;
    
        private ColorStateList mPasswordToggleTintList;
        private boolean mHasPasswordToggleTintList;
    
        public PasswordToggleLayout(Context context) {
            this(context, null);
        }
    
        public PasswordToggleLayout(Context context, AttributeSet attrs) {
            this(context, attrs, 0);
        }
    
        public PasswordToggleLayout(Context context, AttributeSet attrs, int defStyleAttr) {
            super(context, attrs, defStyleAttr);
    
            setOrientation(VERTICAL);
            setWillNotDraw(false);
            setAddStatesFromChildren(true);
    
            mButtonMinSize = (int) TypedValue.applyDimension(
                    TypedValue.COMPLEX_UNIT_DIP, BUTTON_MIN_SIZE,
                    getResources().getDisplayMetrics());
    
            mInputFrame = new FrameLayout(context);
            mInputFrame.setAddStatesFromChildren(true);
            addView(mInputFrame);
    
            TypedArray a = context.obtainStyledAttributes(attrs,
                    R.styleable.PasswordToggleLayout, defStyleAttr,
                    R.style.Widget_Design_TextInputLayout);
    
            mPasswordToggleEnabled = a.getBoolean(
                    R.styleable.PasswordToggleLayout_passwordToggleEnabled, false);
            mPasswordToggleDrawable = a.getDrawable(
                    R.styleable.PasswordToggleLayout_passwordToggleDrawable);
            mPasswordToggleContentDesc = a.getText(
                    R.styleable.PasswordToggleLayout_passwordToggleContentDescription);
            if (a.hasValue(R.styleable.PasswordToggleLayout_passwordToggleTint)) {
                mHasPasswordToggleTintList = true;
                mPasswordToggleTintList = a.getColorStateList(
                        R.styleable.PasswordToggleLayout_passwordToggleTint);
            }
            mPasswordToggleSize = a.getDimensionPixelSize(
                    R.styleable.PasswordToggleLayout_passwordToggleSize,
                    (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,
                            DEFAULT_DRAWABLE_SIZE, getResources().getDisplayMetrics()));
    
            a.recycle();
    
            applyPasswordToggleTint();
        }
    
    
        private void setEditText(EditText editText) {
            // If we already have an EditText, throw an exception
            if (mEditText != null) {
                throw new IllegalArgumentException(
                        "We already have an EditText, can only have one");
            }
    
            mEditText = editText;
    
            final boolean hasPasswordTransformation = hasPasswordTransformation();
    
            updatePasswordToggleView();
    
        }
    
        private void updatePasswordToggleView() {
            if (mEditText == null) {
                // If there is no EditText, there is nothing to update
                return;
            }
    
            if (shouldShowPasswordIcon()) {
                if (mPasswordToggleView == null) {
                    // Keep ratio
                    double w = mPasswordToggleDrawable.getIntrinsicWidth();
                    double h = mPasswordToggleDrawable.getIntrinsicHeight();
                    double scale = mPasswordToggleSize / Math.max(w,h);
                    int scaled_width = (int) (w * scale);
                    int scaled_height = (int) (h * scale);
                    FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(
                            FrameLayout.LayoutParams.WRAP_CONTENT,
                            FrameLayout.LayoutParams.WRAP_CONTENT,
                            Gravity.CENTER_VERTICAL | Gravity.END | Gravity.RIGHT);
                    FrameLayout.LayoutParams lp2 = new FrameLayout.LayoutParams(
                            scaled_width, scaled_height, Gravity.CENTER);
    
                    mPasswordToggleViewGroup = new FrameLayout(this.getContext());
                    mPasswordToggleViewGroup.setMinimumWidth(mButtonMinSize);
                    mPasswordToggleViewGroup.setMinimumHeight(mButtonMinSize);
                    mPasswordToggleViewGroup.setLayoutParams(lp);
                    mInputFrame.addView(mPasswordToggleViewGroup);
    
                    mPasswordToggleViewGroup.setOnClickListener(new View.OnClickListener() {
                        @Override
                        public void onClick(View view) {
                            passwordVisibilityToggleRequested(false);
                        }
                    });
    
                    mPasswordToggleView = new CheckableView(this.getContext());
                    mPasswordToggleView.setBackground(mPasswordToggleDrawable);
                    mPasswordToggleView.setContentDescription(mPasswordToggleContentDesc);
                    mPasswordToggleView.setLayoutParams(lp2);
                    mPasswordToggleViewGroup.addView(mPasswordToggleView);
                }
    
                if (mEditText != null && ViewCompat.getMinimumHeight(mEditText) <= 0) {
                    // We should make sure that the EditText has the same min-height
                    // as the password toggle view. This ensure focus works properly,
                    // and there is no visual jump if the password toggle is enabled/disabled.
                    mEditText.setMinimumHeight(
                            ViewCompat.getMinimumHeight(mPasswordToggleViewGroup));
                }
    
                mPasswordToggleViewGroup.setVisibility(VISIBLE);
    
                mPasswordToggleView.setChecked(mPasswordToggledVisible);
    
                // Need to add a dummy drawable as the end compound drawable so that
                // the text is indented and doesn't display below the toggle view.
                if (mPasswordToggleDummyDrawable == null) {
                    mPasswordToggleDummyDrawable = new ColorDrawable();
                }
                // Important to use mPasswordToggleViewGroup, as mPasswordToggleView
                // wouldn't replicate the margin of the default-drawable.
                mPasswordToggleDummyDrawable.setBounds(
                        0, 0, mPasswordToggleViewGroup.getMeasuredWidth(), 1);
    
                final Drawable[] compounds = TextViewCompat.getCompoundDrawablesRelative(mEditText);
                // Store the user defined end compound drawable so that we can restore it later
                if (compounds[2] != mPasswordToggleDummyDrawable) {
                    mOriginalEditTextEndDrawable = compounds[2];
                }
                TextViewCompat.setCompoundDrawablesRelative(mEditText, compounds[0],
                        compounds[1], mPasswordToggleDummyDrawable, compounds[3]);
    
                // Copy over the EditText's padding so that we match
                mPasswordToggleViewGroup.setPadding(mEditText.getPaddingLeft(),
                        mEditText.getPaddingTop(), mEditText.getPaddingRight(),
                        mEditText.getPaddingBottom());
            } else {
                if (mPasswordToggleViewGroup != null
                        && mPasswordToggleViewGroup.getVisibility() == VISIBLE) {
                    mPasswordToggleViewGroup.setVisibility(View.GONE);
                }
    
                if (mPasswordToggleDummyDrawable != null) {
                    // Make sure that we remove the dummy end compound drawable if
                    // it exists, and then clear it
                    final Drawable[] compounds = TextViewCompat.getCompoundDrawablesRelative(mEditText);
                    if (compounds[2] == mPasswordToggleDummyDrawable) {
                        TextViewCompat.setCompoundDrawablesRelative(mEditText,
                                compounds[0], compounds[1],
                                mOriginalEditTextEndDrawable, compounds[3]);
                        mPasswordToggleDummyDrawable = null;
                    }
                }
            }
        }
    
        private void applyPasswordToggleTint() {
            if (mPasswordToggleDrawable != null && mHasPasswordToggleTintList) {
                mPasswordToggleDrawable = DrawableCompat.wrap(mPasswordToggleDrawable).mutate();
    
                DrawableCompat.setTintList(mPasswordToggleDrawable, mPasswordToggleTintList);
    
                if (mPasswordToggleView != null
                        && mPasswordToggleView.getBackground() != mPasswordToggleDrawable) {
                    mPasswordToggleView.setBackground(mPasswordToggleDrawable);
                }
            }
        }
    
        private void passwordVisibilityToggleRequested(boolean shouldSkipAnimations) {
            if (mPasswordToggleEnabled) {
                // Store the current cursor position
                final int selection = mEditText.getSelectionEnd();
    
                if (hasPasswordTransformation()) {
                    mEditText.setTransformationMethod(null);
                    mPasswordToggledVisible = true;
                } else {
                    mEditText.setTransformationMethod(PasswordTransformationMethod.getInstance());
                    mPasswordToggledVisible = false;
                }
    
                mPasswordToggleView.setChecked(mPasswordToggledVisible);
                if (shouldSkipAnimations) {
                    mPasswordToggleView.jumpDrawablesToCurrentState();
                }
    
                // And restore the cursor position
                mEditText.setSelection(selection);
            }
        }
    
        private boolean hasPasswordTransformation() {
            return mEditText != null
                    && mEditText.getTransformationMethod() instanceof PasswordTransformationMethod;
        }
    
        private boolean shouldShowPasswordIcon() {
            return mPasswordToggleEnabled && (hasPasswordTransformation() || mPasswordToggledVisible);
        }
    
    
        @Override
        public void addView(View child, int index, final ViewGroup.LayoutParams params) {
            if (child instanceof EditText) {
                // Make sure that the EditText is vertically at the bottom,
                // so that it sits on the EditText's underline
                FrameLayout.LayoutParams flp = new FrameLayout.LayoutParams(params);
                flp.gravity = Gravity.CENTER_VERTICAL
                        | (flp.gravity & ~Gravity.VERTICAL_GRAVITY_MASK);
                mInputFrame.addView(child, flp);
    
                // Now use the EditText's LayoutParams as our own and update them
                // to make enough space for the label
                mInputFrame.setLayoutParams(params);
    
                setEditText((EditText) child);
            } else {
                // Carry on adding the View...
                super.addView(child, index, params);
            }
        }
    
        @Override
        protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
            updatePasswordToggleView();
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        }
    
    
        @Override
        public Parcelable onSaveInstanceState() {
            Parcelable superState = super.onSaveInstanceState();
            SavedState ss = new SavedState(superState);
            ss.isPasswordToggledVisible = mPasswordToggledVisible;
            return ss;
        }
    
        @Override
        protected void onRestoreInstanceState(Parcelable state) {
            if (!(state instanceof SavedState)) {
                super.onRestoreInstanceState(state);
                return;
            }
            SavedState ss = (SavedState) state;
            super.onRestoreInstanceState(ss.getSuperState());
            if (ss.isPasswordToggledVisible) {
                passwordVisibilityToggleRequested(true);
            }
            requestLayout();
        }
    
        static class SavedState extends AbsSavedState {
            boolean isPasswordToggledVisible;
    
            SavedState(Parcelable superState) {
                super(superState);
            }
    
            SavedState(Parcel source, ClassLoader loader) {
                super(source, loader);
                isPasswordToggledVisible = (source.readInt() == 1);
    
            }
    
            @Override
            public void writeToParcel(Parcel dest, int flags) {
                super.writeToParcel(dest, flags);
                dest.writeInt(isPasswordToggledVisible ? 1 : 0);
            }
    
            public static final Creator<SavedState> CREATOR = new ClassLoaderCreator<SavedState>() {
                @Override
                public SavedState createFromParcel(Parcel in, ClassLoader loader) {
                    return new SavedState(in, loader);
                }
    
                @Override
                public SavedState createFromParcel(Parcel in) {
                    return new SavedState(in, null);
                }
    
                @Override
                public SavedState[] newArray(int size) {
                    return new SavedState[size];
                }
            };
        }
    
    
        public static class CheckableView extends View {
            private final int[] DRAWABLE_STATE_CHECKED =
                    new int[]{android.R.attr.state_checked};
    
            private boolean mChecked;
    
            public CheckableView(Context context) {
                super(context);
            }
    
            public CheckableView(Context context, @Nullable AttributeSet attrs) {
                super(context, attrs);
            }
    
            public CheckableView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
                super(context, attrs, defStyleAttr);
            }
    
            public void setChecked(boolean checked) {
                if (mChecked != checked) {
                    mChecked = checked;
                    refreshDrawableState();
                }
            }
    
            @Override
            public int[] onCreateDrawableState(int extraSpace) {
                if (mChecked) {
                    return mergeDrawableStates(
                            super.onCreateDrawableState(extraSpace
                                    + DRAWABLE_STATE_CHECKED.length), DRAWABLE_STATE_CHECKED);
                } else {
                    return super.onCreateDrawableState(extraSpace);
                }
            }
        }
    }
    

    And then in an attrs.xml:

    <declare-styleable name="PasswordToggleLayout">
        <attr name="passwordToggleEnabled" format="boolean"/>
        <attr name="passwordToggleDrawable" format="reference"/>
        <attr name="passwordToggleContentDescription" format="string"/>
        <attr name="passwordToggleTint" format="color"/>
        <attr name="passwordToggleSize" format="dimension"/>
    </declare-styleable>