之前在『Android 为 TextView 和 EditText 添加 ICON』一文中介绍了如何为 TextViewEditText 添加 Drawable 作为指示,使交互更加友好,开发更方便,但仍遗留了一个问题,我们能否监听该 Drawable 的点击事件?

这是一个很常见的需求,比如在密码输入框中添加一个用于切换是否显示明文的按钮:

你当然可以使用 EditTextImageButton 组合的方式,这不在本文的讨论范围内。

那么 TextView 或者是继承自 TextViewEditText 有没有提供监听 Drawable 点击事件的方法呢?

很遗憾,并没有。

因此,我们需要自己去实现对应的点击逻辑。

如果你上网搜索对应的内容,介绍的文章真不少,你复制下来到自己的项目上运行,效果或许也的确能够达到你的预期,但我这里要泼个冷水,你拷贝的这份代码很可能是错的——这是我踩过的坑。

其实原理大家都懂,就是实现上可能会忽略一些细节。

我简单先说下原理:

假如我们能够测量出 Drawable 在控件内的区域,就可以对该区域内的触摸事件作监听,也就能够实现监听点击事件的效果。

而难点,恰好就在于如何测量出 Drawable 所在区域,这也是网上很多文章犯错的地方。

由几何知识可以知道,在二维平面中,只要确定了 2 个顶点的坐标,以这两个顶点分别向两坐标轴作垂线就可以绘制出一个矩形区域。需要明确的是,在 Android 中坐标点的计算和我们在数学上不太相同,Android 中坐标原点为左上角顶点,往右为 X 轴正方向,往下为 Y 轴正方向。

接下来只需画个图就能清楚明了。

DrawableLeft 为例:

DrawableLeft 在 X 轴方向上的距离很容易计算,其左侧与 Y 轴的距离实际上就是该 ViewPaddingLeft,其右侧与 Y 轴的距离就是 PaddingLeft 和该 Drawable 的宽度之和。

DrawableLeft 在 Y 轴方向上的距离要稍微复杂一些,因为它会受到 DrawableTop 以及文字区域高度的影响,所以我们不能够直接获取。

这里解释一下,DrawableTop 的影响应该很好理解,也很好处理;文字区域高度的影响,是指不包含 Drawable 的实际文字区域,当文字区域的高度高于 DrawableLeft 的高度时,我们如果不进行计算,获取到的高度可能会比实际上要高,这会造成我们测量出来的区域比实际区域要大。

计算的方法也不难,我们只需要获取到 DrawableLeft 中心在 Y 轴方向上的位置,再增减 Drawable 本身一半的高度,即可得到其上下边界到 X 轴的距离。

这样我们就把 DrawableLeft 所在区域计算出来了,同理 DrawableTop 也能够使用相同的方法计算。

接下来就是 DrawableRight

DrawableRight 右侧在 X 轴方向上的距离实际上就是该 View 的总宽度与 PaddingRight 之差,其左侧在 X 轴方向上的距离就是其右侧在 X 轴方向的距离与该 Drawable 的宽度之差。

DrawableRight 在 Y 轴方向上的距离与 DrawableLeft 的计算方式无异,不再赘述。

用同样的方法,我们也能够计算出 DrawableBottom 的区域。

原理介绍完了,就该上代码了,为了更好的封装,我们可以自定义 View 来实现,以 TextView 为例:

public class DrawableTextView extends AppCompatTextView {

    final int DRAWABLE_LEFT = 0;
    final int DRAWABLE_TOP = 1;
    final int DRAWABLE_RIGHT = 2;
    final int DRAWABLE_BOTTOM = 3;

    Drawable mDrawableLeft, mDrawableTop, mDrawableRight, mDrawableBottom;

    public DrawableTextView(@NonNull Context context) {
        super(context);
    }

    public DrawableTextView(@NonNull Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }

    public DrawableTextView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { 
        super(context, attrs, defStyleAttr);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if (event.getAction() == MotionEvent.ACTION_DOWN) {
            if (onDrawableClickListener != null) {
                mDrawableLeft = getCompoundDrawables()[DRAWABLE_LEFT];
                mDrawableTop = getCompoundDrawables()[DRAWABLE_TOP];
                mDrawableRight = getCompoundDrawables()[DRAWABLE_RIGHT];
                mDrawableBottom = getCompoundDrawables()[DRAWABLE_BOTTOM];
                if (touchDrawableLeft(event)) {
                    onDrawableClickListener.onDrawableLeftClick();
                }
                if (touchDrawableTop(event)) {
                    onDrawableClickListener.onDrawableTopClick();
                }
                if (touchDrawableRight(event)) {
                    onDrawableClickListener.onDrawableRightClick();
                }
                if (touchDrawableBottom(event)) {
                    onDrawableClickListener.onDrawableBottomClick();
                }
            }
        }
        return super.onTouchEvent(event);
    }

    private boolean touchDrawableLeft(MotionEvent event) {
        if (mDrawableLeft == null) {
            return false;
        }
        int drawableHeight = mDrawableLeft.getIntrinsicHeight();
        int drawableWidth = mDrawableLeft.getIntrinsicWidth();
        int topOfDrawable = getCompoundPaddingTop();
        int bottomOfDrawable = getHeight() - getCompoundPaddingBottom();
        double drawableCenterY = 0.5 * (bottomOfDrawable - topOfDrawable) + topOfDrawable;
        Rect bounds = new Rect(getPaddingLeft(),
                (int) (drawableCenterY - 0.5 * drawableHeight),
                getPaddingLeft() + drawableWidth,
                (int) (drawableCenterY + 0.5 * drawableHeight)
        );
        return bounds.contains((int) event.getX(), (int) event.getY());
    }

    private boolean touchDrawableTop(MotionEvent event) {
        if (mDrawableTop == null) {
            return false;
        }
        int drawableHeight = mDrawableTop.getIntrinsicHeight();
        int drawableWidth = mDrawableTop.getIntrinsicWidth();
        int leftOfDrawable = getCompoundPaddingLeft();
        int rightOfDrawable = getWidth() - getCompoundPaddingRight();
        double drawableCenterX = 0.5 * (rightOfDrawable - leftOfDrawable) + leftOfDrawable;
        Rect bounds = new Rect((int) (drawableCenterX - 0.5 * drawableWidth),
                getPaddingTop(),
                (int) (drawableCenterX + 0.5 * drawableWidth),
                getPaddingTop() + drawableHeight);
        return bounds.contains((int) event.getX(), (int) event.getY());
    }

    private boolean touchDrawableRight(MotionEvent event) {
        if (mDrawableRight == null) {
            return false;
        }
        int drawableHeight = mDrawableRight.getIntrinsicHeight();
        int drawableWidth = mDrawableRight.getIntrinsicWidth();
        int topOfDrawable = getCompoundPaddingTop();
        int bottomOfDrawable = getHeight() - getCompoundPaddingBottom();
        double drawableCenterY = 0.5 * (bottomOfDrawable - topOfDrawable) + topOfDrawable;
        Rect bounds = new Rect(getWidth() - getPaddingRight() - drawableWidth,
                (int) (drawableCenterY - 0.5 * drawableHeight),
                getWidth() - getPaddingRight(),
                (int) (drawableCenterY + 0.5 * drawableHeight));
        return bounds.contains((int) event.getX(), (int) event.getY());
    }

    private boolean touchDrawableBottom(MotionEvent event) {
        if (mDrawableBottom == null) {
            return false;
        }
        int drawHeight = mDrawableBottom.getIntrinsicHeight();
        int drawWidth = mDrawableBottom.getIntrinsicWidth();
        int leftOfDrawable = getCompoundPaddingLeft();
        int rightOfDrawable = getWidth() - getCompoundPaddingRight();
        double drawableCenterX = 0.5 * (rightOfDrawable - leftOfDrawable) + leftOfDrawable;
        Rect bounds = new Rect((int) (drawableCenterX - 0.5 * drawWidth),
                getHeight() - getPaddingBottom() - drawHeight,
                (int) (drawableCenterX + 0.5 * drawWidth),
                getHeight() - getPaddingBottom());
        return bounds.contains((int) event.getX(), (int) event.getY());
    }

    public interface OnDrawableClickListener {
        void onDrawableLeftClick();
        void onDrawableTopClick();
        void onDrawableRightClick();
        void onDrawableBottomClick();
    }

    private OnDrawableClickListener onDrawableClickListener;

    public void setOnDrawableClickListener(OnDrawableClickListener listener) {
        this.onDrawableClickListener = listener;
    }
}

代码不短,但我们理解了上面的原理之后也很容易看懂。

我在这里定义了一个 OnDrawableClickListener 接口,用于分别回调 TextView 四个方向上的点击事件。

重点内容在重写的 onTouchEvent() 方法内,这个方法用于监听 TextView 的触摸事件。在该方法内,首先通过 getCompoundDrawables() 方法获取到 Drawable 数组,并通过下标分别拿到四个方向上的 Drawable 对象。紧接着判断触摸事件是否落在对应的 Drawable 区域内,然后回调给 OnDrawableClickListener

触摸区域的判断就根据上面原理写就可以了,解释下几个方法。

getIntrinsicWidth()getIntrinsicHeight() 分别用于获取当前 Drawable 对象的宽高。

getCompoundPaddingLeft() 获取的则是 TextView 左边界到文字实际显示区域的距离,我们可以看下源码:

/**
 * Returns the left padding of the view, plus space for the left Drawable if any.
 */
public int getCompoundPaddingLeft() {
    final Drawables dr = mDrawables;
    if (dr == null || dr.mShowing[Drawables.LEFT] == null) {
        return mPaddingLeft;
    } else {
        return mPaddingLeft + dr.mDrawablePadding + dr.mDrawableSizeLeft;
    }
}

也就是说,当 DrawableLeft 不存在时,它取得的值是 PaddingLeft,而当 DrawableLeft 存在时,它取得的值是 PaddingLeftDrawableLeft 的宽度、DrawablePadding 三者之和。其他方向也同理。

回到刚刚还没解释的问题,当我们知道了文字显示区域距离坐标轴的距离时,Drawable 的中心点也就可以计算出来了。

你可能会有疑问,『Android 为 TextView 和 EditText 添加 ICON』一文不是提到可以通过设置 Gravity 来指定内部元素的对齐方式,那会不会影响到 Drawable 位置的计算?

并不会,因为 Gravity 实际上指定的是文字在文字区域内的显示位置,而不是 Drawable 的位置,即 Drawable 的位置相对于文字区域来说,依然是居中的,所以我们通过文字区域来计算 Drawable 的中心点仍旧可行。

最后,我们将 Drawable 所在的矩形区域计算出来,并判断触摸事件在该 View 上的落点是否在 Drawable 的矩形区域内,就能够判断该 Drawable 是否被点击。

这个自定义的控件写完后,只需替换原来的 TextView 即可,属性与原来无异。

对于继承自 TextViewEditText 也同理,使用上面的自定义方法同样适用。

我这里就使用自定义的 EditText 来写一下文章开头的密码显隐功能。

首先替换布局文件中原来的 EditText 控件:

<com.example.DrawableEditText
    android:id="@+id/password"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:drawableRight="@drawable/ic_eye_off"
    android:inputType="textPassword" />

在逻辑代码中响应其点击事件:

private boolean passwordVisible = false;

void switchPasswordVisibility(final DrawableEditText editText) {
    editText.setOnDrawableClickListener(new DrawableEditText.OnDrawableClickListener() {
        @Override
        public void onDrawableLeftClick() {}

        @Override
        public void onDrawableTopClick() {}

        @Override
        public void onDrawableRightClick() {
            Drawable drawable;
            if (passwordVisible) {
                drawable = getResources().getDrawable(R.drawable.ic_eye_off);
//              editText.setInputType(InputType.TYPE_TEXT_VARIATION_PASSWORD | InputType.TYPE_CLASS_TEXT);
                editText.setTransformationMethod(PasswordTransformationMethod.getInstance());
            } else {
                drawable = getResources().getDrawable(R.drawable.ic_eye_on);
//              editText.setInputType(InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD);
                editText.setTransformationMethod(HideReturnsTransformationMethod.getInstance());
            }
            passwordVisible = !passwordVisible;
            drawable.setBounds(0, 0, drawable.getMinimumWidth(), drawable.getMinimumHeight());
            editText.setCompoundDrawables(null, null, drawable, null);
        }

        @Override
        public void onDrawableBottomClick() {}
    });
}

因为只设置了 DrawableRight,所以这里只需要写入 DrawableRight 的事件即可。

效果如下:

虽然我们在 EditText 的控件属性中常用 android:inputType 属性来设置其为密码的样式,但在这里并不建议使用 setInputType() 方法来作更改,而是使用 setTransformationMethod() 方法,主要是因为 setInputType() 方法有两个坑。

其一,密码从可见状态切回密文状态时,我们以在控件属性中使用的经验,一般会将其设置为:

editText.setInputType(InputType.TYPE_TEXT_VARIATION_PASSWORD);

但是运行之后你会发现,并不会生效:

可以看我上方注释掉的代码中需要同时设置两个属性才能生效:

editText.setInputType(InputType.TYPE_TEXT_VARIATION_PASSWORD | InputType.TYPE_CLASS_TEXT);

对应控件属性中的:

<EditText
    ...
    android:inputType="textPassword" />

因为 textPassword 对应的 InputType 的值为 129,而 InputType.TYPE_TEXT_VARIATION_PASSWORD 对应的值是 128,所以很明显需要和值为 1InputType.TYPE_CLASS_TEXT 做按位或运算。

其二,使用 setInputType() 方法转换后会出现样式不一的情况:

可以见到,当切换为密文状态时,点和点之间的距离增大了,与默认样式不统一,会造成用户困扰。

而使用 setTransformationMethod() 就可以避免上面的情况。