之前在『Android 为 TextView 和 EditText 添加 ICON』一文中介绍了如何为 TextView
和 EditText
添加 Drawable
作为指示,使交互更加友好,开发更方便,但仍遗留了一个问题,我们能否监听该 Drawable
的点击事件?
这是一个很常见的需求,比如在密码输入框中添加一个用于切换是否显示明文的按钮:
你当然可以使用 EditText
加 ImageButton
组合的方式,这不在本文的讨论范围内。
那么 TextView
或者是继承自 TextView
的 EditText
有没有提供监听 Drawable
点击事件的方法呢?
很遗憾,并没有。
因此,我们需要自己去实现对应的点击逻辑。
如果你上网搜索对应的内容,介绍的文章真不少,你复制下来到自己的项目上运行,效果或许也的确能够达到你的预期,但我这里要泼个冷水,你拷贝的这份代码很可能是错的——这是我踩过的坑。
其实原理大家都懂,就是实现上可能会忽略一些细节。
我简单先说下原理:
假如我们能够测量出 Drawable
在控件内的区域,就可以对该区域内的触摸事件作监听,也就能够实现监听点击事件的效果。
而难点,恰好就在于如何测量出 Drawable
所在区域,这也是网上很多文章犯错的地方。
由几何知识可以知道,在二维平面中,只要确定了 2 个顶点的坐标,以这两个顶点分别向两坐标轴作垂线就可以绘制出一个矩形区域。需要明确的是,在 Android 中坐标点的计算和我们在数学上不太相同,Android 中坐标原点为左上角顶点,往右为 X 轴正方向,往下为 Y 轴正方向。
接下来只需画个图就能清楚明了。
以 DrawableLeft
为例:
DrawableLeft
在 X 轴方向上的距离很容易计算,其左侧与 Y 轴的距离实际上就是该 View
的 PaddingLeft
,其右侧与 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
存在时,它取得的值是 PaddingLeft
、DrawableLeft
的宽度、DrawablePadding
三者之和。其他方向也同理。
回到刚刚还没解释的问题,当我们知道了文字显示区域距离坐标轴的距离时,Drawable
的中心点也就可以计算出来了。
你可能会有疑问,『Android 为 TextView 和 EditText 添加 ICON』一文不是提到可以通过设置 Gravity
来指定内部元素的对齐方式,那会不会影响到 Drawable
位置的计算?
并不会,因为 Gravity
实际上指定的是文字在文字区域内的显示位置,而不是 Drawable
的位置,即 Drawable
的位置相对于文字区域来说,依然是居中的,所以我们通过文字区域来计算 Drawable
的中心点仍旧可行。
最后,我们将 Drawable
所在的矩形区域计算出来,并判断触摸事件在该 View
上的落点是否在 Drawable
的矩形区域内,就能够判断该 Drawable
是否被点击。
这个自定义的控件写完后,只需替换原来的 TextView
即可,属性与原来无异。
对于继承自 TextView
的 EditText
也同理,使用上面的自定义方法同样适用。
我这里就使用自定义的 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
,所以很明显需要和值为 1
的 InputType.TYPE_CLASS_TEXT
做按位或运算。
其二,使用 setInputType()
方法转换后会出现样式不一的情况:
可以见到,当切换为密文状态时,点和点之间的距离增大了,与默认样式不统一,会造成用户困扰。
而使用 setTransformationMethod()
就可以避免上面的情况。